chore: clean up redundant comments
This commit is contained in:
@@ -215,10 +215,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Offline Watch Progress Operations
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
||||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
||||||
}
|
}
|
||||||
@@ -446,10 +442,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
return delete(offlineWatchProgress).go();
|
return delete(offlineWatchProgress).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sync Rules Operations
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||||
final query = select(syncRules);
|
final query = select(syncRules);
|
||||||
if (profileId != null) {
|
if (profileId != null) {
|
||||||
@@ -550,10 +542,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Downloaded Media Queries for Watch State Sync
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// Get all downloaded media items (for syncing watch states)
|
/// Get all downloaded media items (for syncing watch states)
|
||||||
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
||||||
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
return (select(downloadedMedia)..where((t) => t.status.equals(DownloadStatus.completed.index))).get();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'app_database.dart';
|
|||||||
import '../models/download_models.dart';
|
import '../models/download_models.dart';
|
||||||
import '../profiles/profile.dart';
|
import '../profiles/profile.dart';
|
||||||
|
|
||||||
/// Extension methods on AppDatabase for download operations
|
|
||||||
extension DownloadDatabaseOperations on AppDatabase {
|
extension DownloadDatabaseOperations on AppDatabase {
|
||||||
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
|
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
|
||||||
if (profileId.isEmpty) return;
|
if (profileId.isEmpty) return;
|
||||||
@@ -76,7 +75,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a new download into the database.
|
|
||||||
Future<void> insertDownload({
|
Future<void> insertDownload({
|
||||||
required String serverId,
|
required String serverId,
|
||||||
String? clientScopeId,
|
String? clientScopeId,
|
||||||
@@ -104,7 +102,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add item to download queue
|
|
||||||
Future<void> addToQueue({
|
Future<void> addToQueue({
|
||||||
required String mediaGlobalKey,
|
required String mediaGlobalKey,
|
||||||
int priority = 0,
|
int priority = 0,
|
||||||
@@ -143,14 +140,12 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
return result?.readTable(downloadQueue);
|
return result?.readTable(downloadQueue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update download status
|
|
||||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||||
await (update(
|
await (update(
|
||||||
downloadedMedia,
|
downloadedMedia,
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update download progress
|
|
||||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||||
DownloadedMediaCompanion(
|
DownloadedMediaCompanion(
|
||||||
@@ -161,7 +156,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update video file path
|
|
||||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||||
DownloadedMediaCompanion(
|
DownloadedMediaCompanion(
|
||||||
@@ -171,16 +165,13 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update artwork paths
|
|
||||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||||
await (update(
|
await (update(
|
||||||
downloadedMedia,
|
downloadedMedia,
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update download error and increment retry count
|
|
||||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||||
// Get current retry count to increment it
|
|
||||||
final existing = await getDownloadedMedia(globalKey);
|
final existing = await getDownloadedMedia(globalKey);
|
||||||
final currentCount = existing?.retryCount ?? 0;
|
final currentCount = existing?.retryCount ?? 0;
|
||||||
|
|
||||||
@@ -189,31 +180,26 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear download error and reset retry count (for retry)
|
|
||||||
Future<void> clearDownloadError(String globalKey) async {
|
Future<void> clearDownloadError(String globalKey) async {
|
||||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove item from queue
|
|
||||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get downloaded media item
|
|
||||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a download
|
|
||||||
Future<void> deleteDownload(String globalKey) async {
|
Future<void> deleteDownload(String globalKey) async {
|
||||||
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all downloaded episodes for a season
|
|
||||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
||||||
String seasonKey, {
|
String seasonKey, {
|
||||||
String? serverId,
|
String? serverId,
|
||||||
@@ -229,7 +215,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all downloaded episodes for a show
|
|
||||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
||||||
String showKey, {
|
String showKey, {
|
||||||
String? serverId,
|
String? serverId,
|
||||||
@@ -245,7 +230,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all downloaded items for a specific server
|
|
||||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||||
}
|
}
|
||||||
@@ -268,14 +252,12 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
return column.equals(clientScopeId);
|
return column.equals(clientScopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the background_downloader task ID for a download
|
|
||||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||||
await (update(
|
await (update(
|
||||||
downloadedMedia,
|
downloadedMedia,
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the background_downloader task ID for a download
|
|
||||||
Future<String?> getBgTaskId(String globalKey) async {
|
Future<String?> getBgTaskId(String globalKey) async {
|
||||||
final item = await getDownloadedMedia(globalKey);
|
final item = await getDownloadedMedia(globalKey);
|
||||||
return item?.bgTaskId;
|
return item?.bgTaskId;
|
||||||
|
|||||||
@@ -146,11 +146,9 @@ Future<void> main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _bootstrapApp() async {
|
Future<void> _bootstrapApp() async {
|
||||||
// Initialize settings first to get saved locale
|
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final savedLocale = settings.read(SettingsService.appLocale);
|
final savedLocale = settings.read(SettingsService.appLocale);
|
||||||
|
|
||||||
// Initialize localization with saved locale
|
|
||||||
unawaited(LocaleSettings.setLocale(savedLocale));
|
unawaited(LocaleSettings.setLocale(savedLocale));
|
||||||
|
|
||||||
// Needed for formatting dates in different locales
|
// Needed for formatting dates in different locales
|
||||||
@@ -165,10 +163,8 @@ Future<void> _bootstrapApp() async {
|
|||||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100MB
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100MB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize services in parallel where possible
|
|
||||||
final futures = <Future<void>>[];
|
final futures = <Future<void>>[];
|
||||||
|
|
||||||
// Initialize window_manager for desktop platforms
|
|
||||||
if (PlatformDetector.isDesktopOS()) {
|
if (PlatformDetector.isDesktopOS()) {
|
||||||
futures.add(windowManager.ensureInitialized());
|
futures.add(windowManager.ensureInitialized());
|
||||||
}
|
}
|
||||||
@@ -178,7 +174,6 @@ Future<void> _bootstrapApp() async {
|
|||||||
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// Initialize PiP service to listen for PiP state changes (Android only).
|
|
||||||
PipService();
|
PipService();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,21 +183,17 @@ Future<void> _bootstrapApp() async {
|
|||||||
// Hook Windows native fullscreen callback (no-op elsewhere).
|
// Hook Windows native fullscreen callback (no-op elsewhere).
|
||||||
NativeWindowService.initialize();
|
NativeWindowService.initialize();
|
||||||
|
|
||||||
// Initialize storage service
|
|
||||||
futures.add(StorageService.getInstance());
|
futures.add(StorageService.getInstance());
|
||||||
|
|
||||||
// Wait for all parallel services to complete
|
|
||||||
await Future.wait(futures);
|
await Future.wait(futures);
|
||||||
|
|
||||||
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
||||||
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
||||||
// is available — keeps the deprecated legacy slots out of runtime paths.
|
// is available — keeps the deprecated legacy slots out of runtime paths.
|
||||||
|
|
||||||
// Initialize logger level based on debug setting
|
|
||||||
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
||||||
setLoggerLevel(debugEnabled);
|
setLoggerLevel(debugEnabled);
|
||||||
|
|
||||||
// Log app version and git commit at startup
|
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
||||||
String renderer = '';
|
String renderer = '';
|
||||||
@@ -211,10 +202,8 @@ Future<void> _bootstrapApp() async {
|
|||||||
}
|
}
|
||||||
appLogger.i('Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer');
|
appLogger.i('Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer');
|
||||||
|
|
||||||
// Initialize download storage service with settings
|
|
||||||
await DownloadStorageService.instance.initialize(settings);
|
await DownloadStorageService.instance.initialize(settings);
|
||||||
|
|
||||||
// Start global fullscreen state monitoring
|
|
||||||
FullscreenStateManager().startMonitoring();
|
FullscreenStateManager().startMonitoring();
|
||||||
|
|
||||||
// Apply "start in fullscreen" preference on Windows/Linux. macOS is
|
// Apply "start in fullscreen" preference on Windows/Linux. macOS is
|
||||||
@@ -236,8 +225,6 @@ Future<void> _bootstrapApp() async {
|
|||||||
// Trakt scrobble service (all platforms)
|
// Trakt scrobble service (all platforms)
|
||||||
await TraktScrobbleService.instance.initialize();
|
await TraktScrobbleService.instance.initialize();
|
||||||
|
|
||||||
// DTD service is available for MCP tooling connection if needed
|
|
||||||
|
|
||||||
// Register bundled shader licenses
|
// Register bundled shader licenses
|
||||||
_registerShaderLicenses();
|
_registerShaderLicenses();
|
||||||
|
|
||||||
@@ -788,7 +775,6 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Existing providers
|
|
||||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||||
create: (_) => UserProfileProvider(),
|
create: (_) => UserProfileProvider(),
|
||||||
update: (context, activeProfile, connections, previous) {
|
update: (context, activeProfile, connections, previous) {
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ class ExternalPlayer {
|
|||||||
int get hashCode => id.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Launch helpers ---
|
|
||||||
|
|
||||||
Future<bool> _launchWithUrl(String url) {
|
Future<bool> _launchWithUrl(String url) {
|
||||||
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
return launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||||
}
|
}
|
||||||
@@ -146,8 +144,6 @@ Future<bool> _launchCustom(String value, String url, CustomPlayerType type) asyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Known Players ---
|
|
||||||
|
|
||||||
class KnownPlayers {
|
class KnownPlayers {
|
||||||
static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl);
|
static final systemDefault = ExternalPlayer(id: 'system_default', name: 'System Default', launch: _launchWithUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
@override
|
@override
|
||||||
bool get supportsSecondarySubtitles => false;
|
bool get supportsSecondarySubtitles => false;
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Platform-Specific Event Handling
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void handlePlayerEvent(String name, Map? data) {
|
void handlePlayerEvent(String name, Map? data) {
|
||||||
// Handle Android-specific events
|
// Handle Android-specific events
|
||||||
@@ -50,10 +46,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
super.handlePlayerEvent(name, data);
|
super.handlePlayerEvent(name, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Initialization
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Memoizes the in-flight init Future so concurrent callers share one
|
// Memoizes the in-flight init Future so concurrent callers share one
|
||||||
// `invoke('initialize')`. ExoPlayer's native handleInitialize is
|
// `invoke('initialize')`. ExoPlayer's native handleInitialize is
|
||||||
// synchronous and would mask a Dart-side race anyway, but we mirror the
|
// synchronous and would mask a Dart-side race anyway, but we mirror the
|
||||||
@@ -100,10 +92,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> open(
|
Future<void> open(
|
||||||
Media media, {
|
Media media, {
|
||||||
@@ -154,10 +142,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await runSeek(() => invoke('seek', {'positionMs': position.inMilliseconds}));
|
await runSeek(() => invoke('seek', {'positionMs': position.inMilliseconds}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Track Selection
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||||
await invoke('selectAudioTrack', {'trackId': track.id});
|
await invoke('selectAudioTrack', {'trackId': track.id});
|
||||||
@@ -173,10 +157,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
|
await invoke('addSubtitleTrack', {'uri': uri, 'title': title, 'language': language, 'select': select});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Volume and Rate
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setVolume(double volume) async {
|
Future<void> setVolume(double volume) async {
|
||||||
await invoke('setVolume', {'volume': volume});
|
await invoke('setVolume', {'volume': volume});
|
||||||
@@ -187,10 +167,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await invoke('setRate', {'rate': rate});
|
await invoke('setRate', {'rate': rate});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// MPV Properties (Compatibility Layer)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setProperty(String name, String value) async {
|
Future<void> setProperty(String name, String value) async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -352,10 +328,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Subtitle Styling (ExoPlayer Native)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Apply subtitle styling to the native ExoPlayer layer.
|
/// Apply subtitle styling to the native ExoPlayer layer.
|
||||||
///
|
///
|
||||||
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
|
/// For non-ASS subtitles, applies CaptionStyleCompat (color, border, background).
|
||||||
@@ -385,10 +357,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Box-fit / video scaling mode
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Apply the box-fit mode to the native ExoPlayer layer.
|
/// Apply the box-fit mode to the native ExoPlayer layer.
|
||||||
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
|
/// Maps to AspectRatioFrameLayout resize mode: 0=FIT, 1=ZOOM, 2=FILL.
|
||||||
Future<void> setBoxFitMode(int mode) async {
|
Future<void> setBoxFitMode(int mode) async {
|
||||||
@@ -396,10 +364,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await invoke('setBoxFitMode', {'mode': mode});
|
await invoke('setBoxFitMode', {'mode': mode});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Frame Rate Matching
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
|
Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async {
|
||||||
if (disposed || !initialized) return false;
|
if (disposed || !initialized) return false;
|
||||||
@@ -423,10 +387,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await invoke('updateFrame');
|
await invoke('updateFrame');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Audio Focus
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requestAudioFocus() async {
|
Future<bool> requestAudioFocus() async {
|
||||||
if (disposed) return false;
|
if (disposed) return false;
|
||||||
@@ -440,10 +400,6 @@ class PlayerAndroid extends PlayerBase {
|
|||||||
await invoke('abandonAudioFocus');
|
await invoke('abandonAudioFocus');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Log Level
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setLogLevel(String level) async {
|
Future<void> setLogLevel(String level) async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ abstract class Player {
|
|||||||
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
|
/// The type of player backend being used (e.g., 'mpv', 'exoplayer').
|
||||||
String get playerType;
|
String get playerType;
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Open a media source for playback.
|
/// Open a media source for playback.
|
||||||
///
|
///
|
||||||
/// [media] - The media source to open.
|
/// [media] - The media source to open.
|
||||||
@@ -78,10 +74,6 @@ abstract class Player {
|
|||||||
/// Seek to a specific position.
|
/// Seek to a specific position.
|
||||||
Future<void> seek(Duration position);
|
Future<void> seek(Duration position);
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Track Selection
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Select an audio track.
|
/// Select an audio track.
|
||||||
Future<void> selectAudioTrack(AudioTrack track);
|
Future<void> selectAudioTrack(AudioTrack track);
|
||||||
|
|
||||||
@@ -107,10 +99,6 @@ abstract class Player {
|
|||||||
/// [select] - Whether to select this track immediately.
|
/// [select] - Whether to select this track immediately.
|
||||||
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
|
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false});
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Volume and Rate
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Set the playback volume.
|
/// Set the playback volume.
|
||||||
///
|
///
|
||||||
/// [volume] - Volume level from 0.0 (muted) to 100.0 (max).
|
/// [volume] - Volume level from 0.0 (muted) to 100.0 (max).
|
||||||
@@ -126,10 +114,6 @@ abstract class Player {
|
|||||||
/// [device] - The audio device to use.
|
/// [device] - The audio device to use.
|
||||||
Future<void> setAudioDevice(AudioDevice device);
|
Future<void> setAudioDevice(AudioDevice device);
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// MPV Properties (Advanced)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Set an MPV property by name.
|
/// Set an MPV property by name.
|
||||||
///
|
///
|
||||||
/// Common properties:
|
/// Common properties:
|
||||||
@@ -162,30 +146,18 @@ abstract class Player {
|
|||||||
/// [args] - Command and arguments as a list of strings.
|
/// [args] - Command and arguments as a list of strings.
|
||||||
Future<void> command(List<String> args);
|
Future<void> command(List<String> args);
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Subtitle Fonts
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Configure subtitle fonts for libass rendering.
|
/// Configure subtitle fonts for libass rendering.
|
||||||
///
|
///
|
||||||
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
|
/// Extracts a comprehensive Unicode font (Go Noto) to the cache directory
|
||||||
/// and sets `sub-fonts-dir` and `sub-font` properties.
|
/// and sets `sub-fonts-dir` and `sub-font` properties.
|
||||||
Future<void> configureSubtitleFonts();
|
Future<void> configureSubtitleFonts();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Passthrough Mode (Audio)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Enable or disable audio passthrough mode.
|
/// Enable or disable audio passthrough mode.
|
||||||
///
|
///
|
||||||
/// When enabled, supported audio codecs (AC3, DTS, etc.) will be
|
/// When enabled, supported audio codecs (AC3, DTS, etc.) will be
|
||||||
/// passed through to the audio device without decoding.
|
/// passed through to the audio device without decoding.
|
||||||
Future<void> setAudioPassthrough(bool enabled);
|
Future<void> setAudioPassthrough(bool enabled);
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Visibility (macOS Metal Layer)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Show or hide the video rendering layer.
|
/// Show or hide the video rendering layer.
|
||||||
///
|
///
|
||||||
/// On macOS, this controls the Metal layer visibility.
|
/// On macOS, this controls the Metal layer visibility.
|
||||||
@@ -201,10 +173,6 @@ abstract class Player {
|
|||||||
/// On other platforms, this is a no-op.
|
/// On other platforms, this is a no-op.
|
||||||
Future<void> updateFrame();
|
Future<void> updateFrame();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Frame Rate Matching (Android)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Set the video frame rate for display refresh rate matching.
|
/// Set the video frame rate for display refresh rate matching.
|
||||||
///
|
///
|
||||||
/// On Android, this hints the system to adjust the display refresh rate
|
/// On Android, this hints the system to adjust the display refresh rate
|
||||||
@@ -231,10 +199,6 @@ abstract class Player {
|
|||||||
/// On other platforms, this is a no-op.
|
/// On other platforms, this is a no-op.
|
||||||
Future<void> clearVideoFrameRate();
|
Future<void> clearVideoFrameRate();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Audio Focus (Android)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Request audio focus before starting playback.
|
/// Request audio focus before starting playback.
|
||||||
///
|
///
|
||||||
/// On Android, this notifies the system that the app wants to play audio,
|
/// On Android, this notifies the system that the app wants to play audio,
|
||||||
@@ -252,10 +216,6 @@ abstract class Player {
|
|||||||
/// On other platforms, this is a no-op.
|
/// On other platforms, this is a no-op.
|
||||||
Future<void> abandonAudioFocus();
|
Future<void> abandonAudioFocus();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Lifecycle
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Whether the player has been disposed.
|
/// Whether the player has been disposed.
|
||||||
bool get disposed;
|
bool get disposed;
|
||||||
|
|
||||||
@@ -264,10 +224,6 @@ abstract class Player {
|
|||||||
/// After calling this, the player instance should not be used.
|
/// After calling this, the player instance should not be used.
|
||||||
Future<void> dispose();
|
Future<void> dispose();
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Factory
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Creates a new player instance.
|
/// Creates a new player instance.
|
||||||
///
|
///
|
||||||
/// Returns a platform-specific implementation:
|
/// Returns a platform-specific implementation:
|
||||||
|
|||||||
@@ -510,10 +510,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
return methodChannel.invokeMethod<T>(method, args);
|
return methodChannel.invokeMethod<T>(method, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Default Implementations
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> playOrPause() async {
|
Future<void> playOrPause() async {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
@@ -576,10 +572,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
// ignore: no-empty-block - base no-op, overridden by platform subclasses
|
||||||
Future<void> setLogLevel(String level) async {}
|
Future<void> setLogLevel(String level) async {}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Subtitle Fonts
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> configureSubtitleFonts() async {
|
Future<void> configureSubtitleFonts() async {
|
||||||
try {
|
try {
|
||||||
@@ -596,10 +588,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Seek helpers
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Run a backend-specific seek call, swallowing the common "not ready" errors
|
/// Run a backend-specific seek call, swallowing the common "not ready" errors
|
||||||
/// the native channel throws when the engine was torn down mid-seek.
|
/// the native channel throws when the engine was torn down mid-seek.
|
||||||
@protected
|
@protected
|
||||||
@@ -615,10 +603,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Debug helpers
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Injects the log + error events that would fire when the server rejects the
|
/// Injects the log + error events that would fire when the server rejects the
|
||||||
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
|
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
|
||||||
/// the in-player debug button to preview the end-to-end detection path
|
/// the in-player debug button to preview the end-to-end detection path
|
||||||
@@ -635,10 +619,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Lifecycle
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
/// but as JSON strings on Android/Windows.
|
/// but as JSON strings on Android/Windows.
|
||||||
static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node';
|
static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node';
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Initialization
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
// Memoizes the in-flight init Future so concurrent callers (e.g. the
|
// Memoizes the in-flight init Future so concurrent callers (e.g. the
|
||||||
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
|
// parallel `requestAudioFocus()` and `setProperty()` paths kicked off in
|
||||||
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
|
// VideoPlayerScreen._initializePlayer) share one `invoke('initialize')`.
|
||||||
@@ -91,10 +87,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Playback Control
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// Opens a content:// URI via the platform channel and returns the raw FD number.
|
/// Opens a content:// URI via the platform channel and returns the raw FD number.
|
||||||
/// Returns null if the call fails.
|
/// Returns null if the call fails.
|
||||||
Future<int?> _openContentFd(String contentUri) async {
|
Future<int?> _openContentFd(String contentUri) async {
|
||||||
@@ -174,10 +166,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
await runSeek(() => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
await runSeek(() => command(['seek', (position.inMilliseconds / 1000.0).toString(), 'absolute']));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Track Selection
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> selectAudioTrack(AudioTrack track) async {
|
Future<void> selectAudioTrack(AudioTrack track) async {
|
||||||
await setProperty('aid', track.id);
|
await setProperty('aid', track.id);
|
||||||
@@ -201,10 +189,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
await command(args);
|
await command(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Volume and Rate
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setVolume(double volume) async {
|
Future<void> setVolume(double volume) async {
|
||||||
await setProperty('volume', volume.toString());
|
await setProperty('volume', volume.toString());
|
||||||
@@ -220,10 +204,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
await setProperty('audio-device', device.name);
|
await setProperty('audio-device', device.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// MPV Properties
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setProperty(String name, String value) async {
|
Future<void> setProperty(String name, String value) async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -245,10 +225,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
await invoke('command', {'args': args});
|
await invoke('command', {'args': args});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Log Level
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setLogLevel(String level) async {
|
Future<void> setLogLevel(String level) async {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -256,10 +232,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
await invoke('setLogLevel', {'level': level});
|
await invoke('setLogLevel', {'level': level});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Passthrough
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setAudioPassthrough(bool enabled) async {
|
Future<void> setAudioPassthrough(bool enabled) async {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -271,10 +243,6 @@ class PlayerNative extends PlayerBase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Platform-Specific Overrides
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateFrame() async {
|
Future<void> updateFrame() async {
|
||||||
if (disposed || !initialized) return;
|
if (disposed || !initialized) return;
|
||||||
|
|||||||
@@ -1488,10 +1488,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
return deletedTitles;
|
return deletedTitles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Sync Rules
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem).
|
/// All sync rules for the active profile (profile-scoped globalKey -> SyncRuleItem).
|
||||||
Map<String, SyncRuleItem> get syncRules => Map.unmodifiable(_syncRules);
|
Map<String, SyncRuleItem> get syncRules => Map.unmodifiable(_syncRules);
|
||||||
|
|
||||||
|
|||||||
@@ -387,10 +387,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
@override
|
@override
|
||||||
void focusActiveTabIfReady() => _focusCurrentTab();
|
void focusActiveTabIfReady() => _focusCurrentTab();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tab chips
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
String _getTabLabel(LiveTvTab tab) {
|
String _getTabLabel(LiveTvTab tab) {
|
||||||
return switch (tab) {
|
return switch (tab) {
|
||||||
LiveTvTab.guide => t.liveTv.guide,
|
LiveTvTab.guide => t.liveTv.guide,
|
||||||
@@ -398,10 +394,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Build
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
List<Widget> _buildTabChipItems() {
|
List<Widget> _buildTabChipItems() {
|
||||||
return [
|
return [
|
||||||
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
for (int i = 0; i < LiveTvTab.values.length; i++) ...[
|
||||||
|
|||||||
@@ -267,10 +267,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
|
await tuneAndNavigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Focus key handling
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
|
|
||||||
@@ -415,10 +411,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Focus helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
LiveTvProgram? _findCurrentProgram(int channelIndex) {
|
LiveTvProgram? _findCurrentProgram(int channelIndex) {
|
||||||
if (channelIndex < 0 || channelIndex >= widget.channels.length) return null;
|
if (channelIndex < 0 || channelIndex >= widget.channels.length) return null;
|
||||||
final channel = widget.channels[channelIndex];
|
final channel = widget.channels[channelIndex];
|
||||||
@@ -502,10 +494,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Build
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -760,10 +748,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Time navigation bar
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) {
|
Widget _timeNavFocusWrap({required Widget child, required int index, required ThemeData theme}) {
|
||||||
final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index;
|
final isFocused = _hasFocus && _focusZone == _GuideZone.timeNav && _timeNavIndex == index;
|
||||||
if (!isFocused) return child;
|
if (!isFocused) return child;
|
||||||
@@ -840,10 +824,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Time header & now indicator
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Widget _buildTimeHeader(ThemeData theme) {
|
Widget _buildTimeHeader(ThemeData theme) {
|
||||||
final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
|
final is24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
|
||||||
final slots = <Widget>[];
|
final slots = <Widget>[];
|
||||||
@@ -872,10 +852,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
return Row(children: slots);
|
return Row(children: slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Channel column
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
|
Widget _buildChannelCell(LiveTvChannel channel, ThemeData theme, {required int index}) {
|
||||||
final multiServer = context.read<MultiServerProvider>();
|
final multiServer = context.read<MultiServerProvider>();
|
||||||
final client = multiServer.getClientForServer(channel.serverId ?? '');
|
final client = multiServer.getClientForServer(channel.serverId ?? '');
|
||||||
@@ -918,10 +894,6 @@ class GuideTabState extends State<GuideTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Program grid
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Widget _buildProgramRow(
|
Widget _buildProgramRow(
|
||||||
LiveTvChannel channel,
|
LiveTvChannel channel,
|
||||||
List<LiveTvProgram> programs,
|
List<LiveTvProgram> programs,
|
||||||
|
|||||||
@@ -200,10 +200,8 @@ class WhatsOnTabState extends State<WhatsOnTab> with LiveTvActionsMixin<WhatsOnT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect)
|
// Hub section — horizontal scrolling row of poster cards (always 2:3 aspect)
|
||||||
// Uses locked focus pattern: single Focus node at hub level, visual index in state.
|
// Uses locked focus pattern: single Focus node at hub level, visual index in state.
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _LiveTvHubSection extends StatefulWidget {
|
class _LiveTvHubSection extends StatefulWidget {
|
||||||
final LiveTvHubResult hub;
|
final LiveTvHubResult hub;
|
||||||
@@ -509,10 +507,6 @@ class _LiveTvHubSectionState extends State<_LiveTvHubSection> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Poster card — always 2:3, shows poster image + title + subtitle
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _LiveTvPosterCard extends StatelessWidget {
|
class _LiveTvPosterCard extends StatelessWidget {
|
||||||
final LiveTvHubEntry entry;
|
final LiveTvHubEntry entry;
|
||||||
final double width;
|
final double width;
|
||||||
|
|||||||
@@ -396,8 +396,6 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
|
|||||||
return options.first.label;
|
return options.first.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Field visibility =====
|
|
||||||
|
|
||||||
bool get _showSortTitle => _mediaType != MediaKind.season;
|
bool get _showSortTitle => _mediaType != MediaKind.season;
|
||||||
bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
|
bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show;
|
||||||
bool get _showReleaseDate => _mediaType != MediaKind.season;
|
bool get _showReleaseDate => _mediaType != MediaKind.season;
|
||||||
@@ -840,8 +838,6 @@ class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Language option lists =====
|
|
||||||
|
|
||||||
// Plex locale codes for metadata agent language.
|
// Plex locale codes for metadata agent language.
|
||||||
const _plexLocaleCodes = [
|
const _plexLocaleCodes = [
|
||||||
'ar-SA',
|
'ar-SA',
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ class _PinEntryDialogState extends State<PinEntryDialog> with SingleTickerProvid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// _TvPinInput — unified 4-digit PIN input
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _TvPinInput extends StatefulWidget {
|
class _TvPinInput extends StatefulWidget {
|
||||||
final ValueChanged<String> onSubmit;
|
final ValueChanged<String> onSubmit;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
@@ -205,8 +201,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
|||||||
if (pin != null) widget.onSubmit(pin);
|
if (pin != null) widget.onSubmit(pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- D-pad / keyboard handling (TV + desktop) --
|
|
||||||
|
|
||||||
void _incrementDigit() {
|
void _incrementDigit() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10;
|
_digits[_activeIndex] = ((_digits[_activeIndex] ?? -1) + 1) % 10;
|
||||||
@@ -324,8 +318,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
|||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Mobile input handling --
|
|
||||||
|
|
||||||
void _onMobileDigitChanged(int index, String value) {
|
void _onMobileDigitChanged(int index, String value) {
|
||||||
if (value.isEmpty) {
|
if (value.isEmpty) {
|
||||||
// Backspace
|
// Backspace
|
||||||
@@ -437,10 +429,6 @@ class _TvPinInputState extends State<_TvPinInput> with KeyRepeatHelper<_TvPinInp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// _DigitBox — single digit display box for TV/desktop
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _DigitBox extends StatelessWidget {
|
class _DigitBox extends StatelessWidget {
|
||||||
final int? digit;
|
final int? digit;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// --- Display ---
|
|
||||||
SettingsSectionHeader(t.settings.display),
|
SettingsSectionHeader(t.settings.display),
|
||||||
_buildThemeSelector(),
|
_buildThemeSelector(),
|
||||||
_buildLanguageSelector(),
|
_buildLanguageSelector(),
|
||||||
@@ -64,13 +63,11 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
_buildShowEpisodeNumberOnCards(),
|
_buildShowEpisodeNumberOnCards(),
|
||||||
_buildShowSeasonPostersOnTabs(),
|
_buildShowSeasonPostersOnTabs(),
|
||||||
|
|
||||||
// --- Home Screen ---
|
|
||||||
SettingsSectionHeader(t.settings.homeScreen),
|
SettingsSectionHeader(t.settings.homeScreen),
|
||||||
_buildShowHeroSection(),
|
_buildShowHeroSection(),
|
||||||
_buildUseGlobalHubs(),
|
_buildUseGlobalHubs(),
|
||||||
_buildShowServerNameOnHubs(),
|
_buildShowServerNameOnHubs(),
|
||||||
|
|
||||||
// --- Navigation ---
|
|
||||||
SettingsSectionHeader(t.settings.navigation),
|
SettingsSectionHeader(t.settings.navigation),
|
||||||
if (Platform.isAndroid) _buildForceTvMode(),
|
if (Platform.isAndroid) _buildForceTvMode(),
|
||||||
if (PlatformDetector.shouldUseSideNavigation(context)) _buildAlwaysKeepSidebarOpen(),
|
if (PlatformDetector.shouldUseSideNavigation(context)) _buildAlwaysKeepSidebarOpen(),
|
||||||
@@ -78,13 +75,11 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
if (!PlatformDetector.shouldUseSideNavigation(context)) _buildShowNavBarLabels(),
|
if (!PlatformDetector.shouldUseSideNavigation(context)) _buildShowNavBarLabels(),
|
||||||
_buildShowUnwatchedCount(),
|
_buildShowUnwatchedCount(),
|
||||||
|
|
||||||
// --- Window (Windows/Linux only) ---
|
|
||||||
if (Platform.isWindows || Platform.isLinux) ...[
|
if (Platform.isWindows || Platform.isLinux) ...[
|
||||||
SettingsSectionHeader(t.settings.window),
|
SettingsSectionHeader(t.settings.window),
|
||||||
_buildStartInFullscreen(),
|
_buildStartInFullscreen(),
|
||||||
],
|
],
|
||||||
|
|
||||||
// --- Content ---
|
|
||||||
SettingsSectionHeader(t.settings.content),
|
SettingsSectionHeader(t.settings.content),
|
||||||
_buildLiveTvDefaultFavorites(),
|
_buildLiveTvDefaultFavorites(),
|
||||||
_buildHideSpoilers(),
|
_buildHideSpoilers(),
|
||||||
@@ -98,8 +93,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Display section ---
|
|
||||||
|
|
||||||
Widget _buildThemeSelector() {
|
Widget _buildThemeSelector() {
|
||||||
return Consumer<ThemeProvider>(
|
return Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, child) {
|
builder: (context, themeProvider, child) {
|
||||||
@@ -251,8 +244,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Home Screen section ---
|
|
||||||
|
|
||||||
Widget _buildShowHeroSection() => _buildBoolToggle(
|
Widget _buildShowHeroSection() => _buildBoolToggle(
|
||||||
icon: Symbols.featured_play_list_rounded,
|
icon: Symbols.featured_play_list_rounded,
|
||||||
title: t.settings.showHeroSection,
|
title: t.settings.showHeroSection,
|
||||||
@@ -277,8 +268,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
setter: (p, v) => p.setShowServerNameOnHubs(v),
|
setter: (p, v) => p.setShowServerNameOnHubs(v),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Navigation section ---
|
|
||||||
|
|
||||||
Widget _buildAlwaysKeepSidebarOpen() => _buildBoolToggle(
|
Widget _buildAlwaysKeepSidebarOpen() => _buildBoolToggle(
|
||||||
icon: Symbols.dock_to_left_rounded,
|
icon: Symbols.dock_to_left_rounded,
|
||||||
title: t.settings.alwaysKeepSidebarOpen,
|
title: t.settings.alwaysKeepSidebarOpen,
|
||||||
@@ -311,8 +300,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
setter: (p, v) => p.setShowUnwatchedCount(v),
|
setter: (p, v) => p.setShowUnwatchedCount(v),
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Content section ---
|
|
||||||
|
|
||||||
Widget _buildLiveTvDefaultFavorites() => _buildBoolToggle(
|
Widget _buildLiveTvDefaultFavorites() => _buildBoolToggle(
|
||||||
icon: Symbols.star_rounded,
|
icon: Symbols.star_rounded,
|
||||||
title: t.settings.liveTvDefaultFavorites,
|
title: t.settings.liveTvDefaultFavorites,
|
||||||
@@ -400,8 +387,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
String _getLanguageDisplayName(AppLocale locale) {
|
String _getLanguageDisplayName(AppLocale locale) {
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case AppLocale.en:
|
case AppLocale.en:
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// --- Player ---
|
|
||||||
SettingsSectionHeader(t.settings.player),
|
SettingsSectionHeader(t.settings.player),
|
||||||
if (Platform.isAndroid) _buildPlayerBackendSelector(),
|
if (Platform.isAndroid) _buildPlayerBackendSelector(),
|
||||||
_buildExternalPlayerTile(),
|
_buildExternalPlayerTile(),
|
||||||
@@ -139,12 +138,10 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
_buildBufferSizeSelector(),
|
_buildBufferSizeSelector(),
|
||||||
_buildDefaultQualityTile(),
|
_buildDefaultQualityTile(),
|
||||||
|
|
||||||
// --- Subtitles & Config ---
|
|
||||||
SettingsSectionHeader(t.settings.subtitlesAndConfig),
|
SettingsSectionHeader(t.settings.subtitlesAndConfig),
|
||||||
_buildSubtitleStylingTile(),
|
_buildSubtitleStylingTile(),
|
||||||
if (!Platform.isAndroid || !_useExoPlayer) _buildMpvConfigTile(),
|
if (!Platform.isAndroid || !_useExoPlayer) _buildMpvConfigTile(),
|
||||||
|
|
||||||
// --- Seek & Timing ---
|
|
||||||
SettingsSectionHeader(t.settings.seekAndTiming),
|
SettingsSectionHeader(t.settings.seekAndTiming),
|
||||||
_buildSmallSkipDuration(),
|
_buildSmallSkipDuration(),
|
||||||
_buildLargeSkipDuration(),
|
_buildLargeSkipDuration(),
|
||||||
@@ -152,14 +149,12 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
_buildDefaultSleepTimer(),
|
_buildDefaultSleepTimer(),
|
||||||
_buildMaxVolume(),
|
_buildMaxVolume(),
|
||||||
|
|
||||||
// --- Behavior ---
|
|
||||||
SettingsSectionHeader(t.settings.behavior),
|
SettingsSectionHeader(t.settings.behavior),
|
||||||
if (DiscordRPCService.isAvailable) _buildDiscordRPC(),
|
if (DiscordRPCService.isAvailable) _buildDiscordRPC(),
|
||||||
if (PlatformDetector.shouldActAsRemoteHost(context)) _buildCompanionRemoteServer(),
|
if (PlatformDetector.shouldActAsRemoteHost(context)) _buildCompanionRemoteServer(),
|
||||||
_buildRememberTrackSelections(),
|
_buildRememberTrackSelections(),
|
||||||
if (!isMobile) _buildClickVideoTogglesPlayback(),
|
if (!isMobile) _buildClickVideoTogglesPlayback(),
|
||||||
|
|
||||||
// --- Auto-Skip ---
|
|
||||||
SettingsSectionHeader(t.settings.autoSkip),
|
SettingsSectionHeader(t.settings.autoSkip),
|
||||||
_buildAutoSkipIntro(),
|
_buildAutoSkipIntro(),
|
||||||
_buildAutoSkipCredits(),
|
_buildAutoSkipCredits(),
|
||||||
@@ -174,8 +169,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Player section ---
|
|
||||||
|
|
||||||
Widget _buildPlayerBackendSelector() {
|
Widget _buildPlayerBackendSelector() {
|
||||||
return SegmentedSetting<bool>(
|
return SegmentedSetting<bool>(
|
||||||
icon: Symbols.play_circle_rounded,
|
icon: Symbols.play_circle_rounded,
|
||||||
@@ -369,8 +362,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Subtitles & Config section ---
|
|
||||||
|
|
||||||
Widget _buildSubtitleStylingTile() {
|
Widget _buildSubtitleStylingTile() {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
||||||
@@ -395,8 +386,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Seek & Timing section ---
|
|
||||||
|
|
||||||
Widget _buildSmallSkipDuration() {
|
Widget _buildSmallSkipDuration() {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
|
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
|
||||||
@@ -515,8 +504,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Behavior section ---
|
|
||||||
|
|
||||||
Widget _buildDiscordRPC() {
|
Widget _buildDiscordRPC() {
|
||||||
return SwitchListTile(
|
return SwitchListTile(
|
||||||
secondary: const AppIcon(Symbols.chat_rounded, fill: 1),
|
secondary: const AppIcon(Symbols.chat_rounded, fill: 1),
|
||||||
@@ -570,8 +557,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auto-Skip section ---
|
|
||||||
|
|
||||||
Widget _buildAutoSkipIntro() {
|
Widget _buildAutoSkipIntro() {
|
||||||
return SwitchListTile(
|
return SwitchListTile(
|
||||||
secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1),
|
secondary: const AppIcon(Symbols.fast_forward_rounded, fill: 1),
|
||||||
|
|||||||
@@ -168,40 +168,28 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)),
|
ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)),
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// --- Donate (non-store builds only) ---
|
|
||||||
if (DonationService.isEnabled) _buildDonateTile(),
|
if (DonationService.isEnabled) _buildDonateTile(),
|
||||||
|
|
||||||
// --- Appearance (navigation tile) ---
|
|
||||||
_buildAppearanceTile(),
|
_buildAppearanceTile(),
|
||||||
|
|
||||||
// --- Playback (navigation tile) ---
|
|
||||||
_buildPlaybackTile(),
|
_buildPlaybackTile(),
|
||||||
|
|
||||||
// --- Trackers (unified hub: Trakt + MAL + AniList + Simkl) ---
|
|
||||||
_buildTrackersTile(),
|
_buildTrackersTile(),
|
||||||
|
|
||||||
// --- Connections (Jellyfin servers) ---
|
|
||||||
_buildConnectionsSection(),
|
_buildConnectionsSection(),
|
||||||
|
|
||||||
// --- Profiles (kids mode / multi-user) ---
|
|
||||||
_buildProfilesSection(),
|
_buildProfilesSection(),
|
||||||
|
|
||||||
// --- Downloads (inline) ---
|
|
||||||
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
|
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
|
||||||
|
|
||||||
// --- Keyboard Shortcuts (inline, conditional) ---
|
|
||||||
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
|
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
|
||||||
|
|
||||||
// --- Advanced (inline) ---
|
|
||||||
_buildAdvancedSection(),
|
_buildAdvancedSection(),
|
||||||
|
|
||||||
// --- Updates (conditional) ---
|
|
||||||
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
|
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
|
||||||
|
|
||||||
// --- Backup (hidden on Apple TV — no file picker / storage) ---
|
|
||||||
if (!PlatformDetector.isAppleTV()) _buildBackupSection(),
|
if (!PlatformDetector.isAppleTV()) _buildBackupSection(),
|
||||||
|
|
||||||
// --- About ---
|
|
||||||
ListTile(
|
ListTile(
|
||||||
focusNode: _focusTracker.get(_kAbout),
|
focusNode: _focusTracker.get(_kAbout),
|
||||||
leading: const AppIcon(Symbols.info_rounded, fill: 1),
|
leading: const AppIcon(Symbols.info_rounded, fill: 1),
|
||||||
@@ -615,8 +603,6 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Dialogs ---
|
|
||||||
|
|
||||||
Future<void> _showDownloadLocationDialog() async {
|
Future<void> _showDownloadLocationDialog() async {
|
||||||
final storageService = DownloadStorageService.instance;
|
final storageService = DownloadStorageService.instance;
|
||||||
final isCustom = storageService.isUsingCustomPath();
|
final isCustom = storageService.isUsingCustomPath();
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// --- Text ---
|
|
||||||
SettingsSectionHeader(t.subtitlingStyling.text),
|
SettingsSectionHeader(t.subtitlingStyling.text),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
leading: const AppIcon(Symbols.subtitles_rounded, fill: 1),
|
||||||
@@ -279,7 +278,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- Border ---
|
|
||||||
SettingsSectionHeader(t.subtitlingStyling.border),
|
SettingsSectionHeader(t.subtitlingStyling.border),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const AppIcon(Symbols.border_style_rounded, fill: 1),
|
leading: const AppIcon(Symbols.border_style_rounded, fill: 1),
|
||||||
@@ -319,7 +317,6 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- Background ---
|
|
||||||
SettingsSectionHeader(t.subtitlingStyling.background),
|
SettingsSectionHeader(t.subtitlingStyling.background),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const AppIcon(Symbols.opacity_rounded, fill: 1),
|
leading: const AppIcon(Symbols.opacity_rounded, fill: 1),
|
||||||
|
|||||||
@@ -262,12 +262,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
StreamSubscription<double>? _mediaControlsRateSubscription;
|
StreamSubscription<double>? _mediaControlsRateSubscription;
|
||||||
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
|
StreamSubscription<bool>? _mediaControlsSeekableSubscription;
|
||||||
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
|
StreamSubscription<Map<String, bool>>? _serverStatusSubscription;
|
||||||
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
|
bool _isReplacingWithVideo = false;
|
||||||
bool _isDisposingForNavigation = false;
|
bool _isDisposingForNavigation = false;
|
||||||
bool _isHandlingBack = false;
|
bool _isHandlingBack = false;
|
||||||
ScrubPreviewSource? _scrubPreviewSource;
|
ScrubPreviewSource? _scrubPreviewSource;
|
||||||
|
|
||||||
// Live TV channel navigation
|
|
||||||
int _liveChannelIndex = -1;
|
int _liveChannelIndex = -1;
|
||||||
String? _liveChannelName;
|
String? _liveChannelName;
|
||||||
MediaServerClient? _liveClient;
|
MediaServerClient? _liveClient;
|
||||||
@@ -287,7 +286,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
// the Jellyfin started/progress/stopped transition.
|
// the Jellyfin started/progress/stopped transition.
|
||||||
JellyfinLiveSessionTracker _jellyfinLiveSession = JellyfinLiveSessionTracker();
|
JellyfinLiveSessionTracker _jellyfinLiveSession = JellyfinLiveSessionTracker();
|
||||||
|
|
||||||
// Live TV time-shift
|
|
||||||
CaptureBuffer? _captureBuffer;
|
CaptureBuffer? _captureBuffer;
|
||||||
int? _programBeginsAt;
|
int? _programBeginsAt;
|
||||||
double _streamStartEpoch = 0;
|
double _streamStartEpoch = 0;
|
||||||
@@ -299,16 +297,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
int _liveStreamFallbackLevel = 0;
|
int _liveStreamFallbackLevel = 0;
|
||||||
bool _isRetryingLiveStream = false;
|
bool _isRetryingLiveStream = false;
|
||||||
|
|
||||||
// Auto-play next episode
|
|
||||||
Timer? _autoPlayTimer;
|
Timer? _autoPlayTimer;
|
||||||
int _autoPlayCountdown = 5;
|
int _autoPlayCountdown = 5;
|
||||||
bool _completionTriggered = false;
|
bool _completionTriggered = false;
|
||||||
|
|
||||||
// Play Next dialog focus nodes (for TV D-pad navigation)
|
|
||||||
late final FocusNode _playNextCancelFocusNode;
|
late final FocusNode _playNextCancelFocusNode;
|
||||||
late final FocusNode _playNextConfirmFocusNode;
|
late final FocusNode _playNextConfirmFocusNode;
|
||||||
|
|
||||||
// "Still watching?" prompt (sleep timer)
|
|
||||||
bool _showStillWatchingPrompt = false;
|
bool _showStillWatchingPrompt = false;
|
||||||
int _stillWatchingCountdown = 30;
|
int _stillWatchingCountdown = 30;
|
||||||
Timer? _stillWatchingTimer;
|
Timer? _stillWatchingTimer;
|
||||||
@@ -345,7 +340,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled) ||
|
((Platform.isIOS || Platform.isMacOS) && _autoPipEnabled) ||
|
||||||
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
|
(Platform.isAndroid && _androidAutoPipTransitionInFlight);
|
||||||
|
|
||||||
// Services
|
|
||||||
MediaControlsManager? _mediaControlsManager;
|
MediaControlsManager? _mediaControlsManager;
|
||||||
PlaybackProgressTracker? _progressTracker;
|
PlaybackProgressTracker? _progressTracker;
|
||||||
VideoFilterManager? _videoFilterManager;
|
VideoFilterManager? _videoFilterManager;
|
||||||
@@ -354,10 +348,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
AmbientLightingService? _ambientLightingService;
|
AmbientLightingService? _ambientLightingService;
|
||||||
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService();
|
||||||
|
|
||||||
// Watch Together provider reference (stored early to use in dispose)
|
|
||||||
WatchTogetherProvider? _watchTogetherProvider;
|
WatchTogetherProvider? _watchTogetherProvider;
|
||||||
|
|
||||||
// Companion remote state (stored early for use in dispose)
|
|
||||||
CompanionRemoteProvider? _companionRemoteProvider;
|
CompanionRemoteProvider? _companionRemoteProvider;
|
||||||
VoidCallback? _savedOnHome;
|
VoidCallback? _savedOnHome;
|
||||||
|
|
||||||
@@ -373,12 +365,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
|
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
|
||||||
|
|
||||||
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
|
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
|
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false); // Track if navigating away (for black overlay)
|
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
|
||||||
final ValueNotifier<bool> _controlsVisible = ValueNotifier<bool>(
|
final ValueNotifier<bool> _controlsVisible = ValueNotifier<bool>(true);
|
||||||
true,
|
|
||||||
); // Track if video controls are visible (for popup positioning)
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -388,17 +378,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_activeId = widget.metadata.id;
|
_activeId = widget.metadata.id;
|
||||||
_activeMediaIndex = widget.selectedMediaIndex;
|
_activeMediaIndex = widget.selectedMediaIndex;
|
||||||
|
|
||||||
// Transcode session identifiers — reused across quality/version/audio
|
// Reused across quality/version/audio switches so the server-side
|
||||||
// switches so the server-side transcode session is preserved.
|
// transcode session is preserved.
|
||||||
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier();
|
_playbackSessionIdentifier = widget.reusedSessionIdentifier ?? generateSessionIdentifier();
|
||||||
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier();
|
_playbackTranscodeSessionId = widget.reusedTranscodeSessionId ?? generateSessionIdentifier();
|
||||||
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
_selectedAudioStreamId = widget.selectedAudioStreamId;
|
||||||
_effectiveIsOffline = widget.isOffline;
|
_effectiveIsOffline = widget.isOffline;
|
||||||
// Quality preset is resolved later when the SettingsProvider is available;
|
|
||||||
// see _resolveQualityPreset() called from _initializePlayer.
|
|
||||||
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
_selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original;
|
||||||
|
|
||||||
// Initialize live TV channel tracking
|
|
||||||
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
_liveChannelIndex = widget.liveCurrentChannelIndex ?? -1;
|
||||||
_liveChannelName = widget.liveChannelName;
|
_liveChannelName = widget.liveChannelName;
|
||||||
_liveClient = widget.liveClient;
|
_liveClient = widget.liveClient;
|
||||||
@@ -411,11 +398,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: widget.liveSessionIdentifier);
|
_jellyfinLiveSession = JellyfinLiveSessionTracker(playSessionId: widget.liveSessionIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Play Next dialog focus nodes
|
|
||||||
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
_playNextCancelFocusNode = FocusNode(debugLabel: 'PlayNextCancel');
|
||||||
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
|
_playNextConfirmFocusNode = FocusNode(debugLabel: 'PlayNextConfirm');
|
||||||
|
|
||||||
// Initialize "Still watching?" dialog focus nodes
|
|
||||||
_stillWatchingPauseFocusNode = FocusNode(debugLabel: 'StillWatchingPause');
|
_stillWatchingPauseFocusNode = FocusNode(debugLabel: 'StillWatchingPause');
|
||||||
_stillWatchingContinueFocusNode = FocusNode(debugLabel: 'StillWatchingContinue');
|
_stillWatchingContinueFocusNode = FocusNode(debugLabel: 'StillWatchingContinue');
|
||||||
|
|
||||||
@@ -437,7 +422,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
appLogger.d('Preferred subtitle track: $subtitleDesc');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current item in playback state provider
|
|
||||||
try {
|
try {
|
||||||
final playbackState = context.read<PlaybackStateProvider>();
|
final playbackState = context.read<PlaybackStateProvider>();
|
||||||
|
|
||||||
@@ -459,22 +443,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Provider might not be available yet during initialization
|
|
||||||
appLogger.d('Deferred playback state update (provider not ready)', error: e);
|
appLogger.d('Deferred playback state update (provider not ready)', error: e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register app lifecycle observer
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
// Wire companion remote playback callbacks
|
|
||||||
_setupCompanionRemoteCallbacks();
|
_setupCompanionRemoteCallbacks();
|
||||||
|
|
||||||
// Show "Still watching?" prompt when sleep timer fires
|
|
||||||
_sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) {
|
_sleepTimerSubscription = SleepTimerService().onPrompt.listen((_) {
|
||||||
if (mounted) _showStillWatchingDialog();
|
if (mounted) _showStillWatchingDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize player asynchronously with buffer size from settings
|
|
||||||
_initializePlayer();
|
_initializePlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,7 +661,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _playerInitializationError = null);
|
setState(() => _playerInitializationError = null);
|
||||||
}
|
}
|
||||||
// Load buffer size from settings
|
|
||||||
final settingsService = await SettingsService.getInstance();
|
final settingsService = await SettingsService.getInstance();
|
||||||
_videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled);
|
_videoPlayerNavigationEnabled = settingsService.read(SettingsService.videoPlayerNavigationEnabled);
|
||||||
_autoPipEnabled = settingsService.read(SettingsService.autoPip);
|
_autoPipEnabled = settingsService.read(SettingsService.autoPip);
|
||||||
@@ -692,14 +670,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final debugLoggingEnabled = settingsService.read(SettingsService.enableDebugLogging);
|
final debugLoggingEnabled = settingsService.read(SettingsService.enableDebugLogging);
|
||||||
final useExoPlayer = settingsService.read(SettingsService.useExoPlayer);
|
final useExoPlayer = settingsService.read(SettingsService.useExoPlayer);
|
||||||
|
|
||||||
// Initialize Windows display mode service.
|
|
||||||
if (Platform.isWindows) {
|
if (Platform.isWindows) {
|
||||||
_displayModeService = DisplayModeService(settingsService, FullscreenStateManager());
|
_displayModeService = DisplayModeService(settingsService, FullscreenStateManager());
|
||||||
await _displayModeService!.syncWithNative();
|
await _displayModeService!.syncWithNative();
|
||||||
FullscreenStateManager().addListener(_onFullscreenChanged);
|
FullscreenStateManager().addListener(_onFullscreenChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create player (on Android, uses ExoPlayer by default, MPV as fallback)
|
|
||||||
player = Player(useExoPlayer: useExoPlayer);
|
player = Player(useExoPlayer: useExoPlayer);
|
||||||
_playerBackendLabel = player!.playerType;
|
_playerBackendLabel = player!.playerType;
|
||||||
|
|
||||||
@@ -771,7 +747,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (bufferSizeMB > 0) {
|
if (bufferSizeMB > 0) {
|
||||||
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
final bufferSizeBytes = bufferSizeMB * 1024 * 1024;
|
||||||
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
await player!.setProperty('demuxer-max-bytes', bufferSizeBytes.toString());
|
||||||
// Set back-buffer to 1/4 of forward buffer
|
|
||||||
final backBytes = bufferSizeBytes ~/ 4;
|
final backBytes = bufferSizeBytes ~/ 4;
|
||||||
await player!.setProperty('demuxer-max-back-bytes', backBytes.toString());
|
await player!.setProperty('demuxer-max-back-bytes', backBytes.toString());
|
||||||
}
|
}
|
||||||
@@ -791,7 +766,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
autoBackMB = 48;
|
autoBackMB = 48;
|
||||||
}
|
}
|
||||||
if (bufferSizeMB == 0) {
|
if (bufferSizeMB == 0) {
|
||||||
// Auto mode: cap both forward and back buffer based on heap
|
|
||||||
int autoForwardMB;
|
int autoForwardMB;
|
||||||
if (heapMB <= 256) {
|
if (heapMB <= 256) {
|
||||||
autoForwardMB = 32;
|
autoForwardMB = 32;
|
||||||
@@ -813,7 +787,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await player!.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
await player!.setLogLevel(debugLoggingEnabled ? 'v' : 'warn');
|
||||||
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
await player!.setProperty('hwdec', _getHwdecValue(enableHardwareDecoding));
|
||||||
|
|
||||||
// Subtitle styling
|
|
||||||
await player!.setProperty('sub-font-size', settingsService.read(SettingsService.subtitleFontSize).toString());
|
await player!.setProperty('sub-font-size', settingsService.read(SettingsService.subtitleFontSize).toString());
|
||||||
await player!.setProperty('sub-color', settingsService.read(SettingsService.subtitleTextColor));
|
await player!.setProperty('sub-color', settingsService.read(SettingsService.subtitleTextColor));
|
||||||
await player!.setProperty('sub-border-size', settingsService.read(SettingsService.subtitleBorderSize).toString());
|
await player!.setProperty('sub-border-size', settingsService.read(SettingsService.subtitleBorderSize).toString());
|
||||||
@@ -833,7 +806,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await player!.setProperty('sub-ass-video-aspect-override', '1');
|
await player!.setProperty('sub-ass-video-aspect-override', '1');
|
||||||
await player!.setProperty('sub-pos', settingsService.read(SettingsService.subtitlePosition).toString());
|
await player!.setProperty('sub-pos', settingsService.read(SettingsService.subtitlePosition).toString());
|
||||||
|
|
||||||
// Platform-specific settings
|
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
await player!.setProperty('audio-exclusive', 'yes');
|
await player!.setProperty('audio-exclusive', 'yes');
|
||||||
}
|
}
|
||||||
@@ -851,26 +823,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await player!.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
|
await player!.setProperty('hdr-enabled', enableHDR ? 'yes' : 'no');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply audio sync offset
|
|
||||||
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
|
final audioSyncOffset = settingsService.read(SettingsService.audioSyncOffset);
|
||||||
if (audioSyncOffset != 0) {
|
if (audioSyncOffset != 0) {
|
||||||
final offsetSeconds = audioSyncOffset / 1000.0;
|
final offsetSeconds = audioSyncOffset / 1000.0;
|
||||||
await player!.setProperty('audio-delay', offsetSeconds.toString());
|
await player!.setProperty('audio-delay', offsetSeconds.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply subtitle sync offset
|
|
||||||
final subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset);
|
final subtitleSyncOffset = settingsService.read(SettingsService.subtitleSyncOffset);
|
||||||
if (subtitleSyncOffset != 0) {
|
if (subtitleSyncOffset != 0) {
|
||||||
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
final offsetSeconds = subtitleSyncOffset / 1000.0;
|
||||||
await player!.setProperty('sub-delay', offsetSeconds.toString());
|
await player!.setProperty('sub-delay', offsetSeconds.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply audio normalization (loudnorm filter)
|
|
||||||
if (settingsService.read(SettingsService.audioNormalization)) {
|
if (settingsService.read(SettingsService.audioNormalization)) {
|
||||||
await player!.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4');
|
await player!.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom MPV config entries
|
|
||||||
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
|
final customMpvConfig = SettingsService.parseMpvConfigText(settingsService.read(SettingsService.mpvConfigText));
|
||||||
for (final entry in customMpvConfig.entries) {
|
for (final entry in customMpvConfig.entries) {
|
||||||
try {
|
try {
|
||||||
@@ -881,15 +849,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set max volume limit for volume boost
|
|
||||||
final maxVolume = settingsService.read(SettingsService.maxVolume);
|
final maxVolume = settingsService.read(SettingsService.maxVolume);
|
||||||
await player!.setProperty('volume-max', maxVolume.toString());
|
await player!.setProperty('volume-max', maxVolume.toString());
|
||||||
|
|
||||||
// Apply saved volume (clamped to max volume)
|
|
||||||
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
||||||
unawaited(player!.setVolume(savedVolume));
|
unawaited(player!.setVolume(savedVolume));
|
||||||
|
|
||||||
// Notify that player is ready
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isPlayerInitialized = true;
|
_isPlayerInitialized = true;
|
||||||
@@ -906,7 +871,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
appLogger.d('Wakelock enabled for video playback');
|
appLogger.d('Wakelock enabled for video playback');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the video URL and start playback
|
|
||||||
await _startPlayback();
|
await _startPlayback();
|
||||||
|
|
||||||
// Set fullscreen mode and orientation based on rotation lock setting
|
// Set fullscreen mode and orientation based on rotation lock setting
|
||||||
@@ -941,7 +905,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (_positionSubscription != null) _positionSubscription!.cancel(),
|
if (_positionSubscription != null) _positionSubscription!.cancel(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Listen to playback state changes
|
|
||||||
_playingSubscription = player!.streams.playing.listen(_onPlayingStateChanged);
|
_playingSubscription = player!.streams.playing.listen(_onPlayingStateChanged);
|
||||||
|
|
||||||
// Listen to completion. When mpv emits completed=false (file-loaded after a
|
// Listen to completion. When mpv emits completed=false (file-loaded after a
|
||||||
@@ -955,7 +918,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_onVideoCompleted(done);
|
_onVideoCompleted(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to MPV errors
|
|
||||||
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
_errorSubscription = player!.streams.error.listen(_onPlayerError);
|
||||||
|
|
||||||
// warn is included so we can catch ffmpeg's "HTTP error 500" line in
|
// warn is included so we can catch ffmpeg's "HTTP error 500" line in
|
||||||
@@ -964,12 +926,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||||
.listen(_onPlayerLog);
|
.listen(_onPlayerLog);
|
||||||
|
|
||||||
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
|
||||||
if (Platform.isAndroid && useExoPlayer) {
|
if (Platform.isAndroid && useExoPlayer) {
|
||||||
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
_backendSwitchedSubscription = player!.streams.backendSwitched.listen((_) => _onBackendSwitched());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to buffering state
|
|
||||||
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
_bufferingSubscription = player!.streams.buffering.listen((isBuffering) {
|
||||||
_isBuffering.value = isBuffering;
|
_isBuffering.value = isBuffering;
|
||||||
});
|
});
|
||||||
@@ -994,7 +954,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen to playback restart to detect first frame ready
|
|
||||||
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
|
_playbackRestartSubscription = player!.streams.playbackRestart.listen((_) async {
|
||||||
_lastLogError = null;
|
_lastLogError = null;
|
||||||
_sawServer500 = false;
|
_sawServer500 = false;
|
||||||
@@ -1003,12 +962,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_hasFirstFrame.value = true;
|
_hasFirstFrame.value = true;
|
||||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
|
||||||
|
|
||||||
// Apply frame rate matching on Android if enabled
|
|
||||||
if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
|
if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
|
||||||
await _applyFrameRateMatching();
|
await _applyFrameRateMatching();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Windows display mode matching (refresh rate, HDR)
|
|
||||||
if (Platform.isWindows && _displayModeService != null) {
|
if (Platform.isWindows && _displayModeService != null) {
|
||||||
await _applyWindowsDisplayMatching();
|
await _applyWindowsDisplayMatching();
|
||||||
}
|
}
|
||||||
@@ -1016,7 +973,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_trackManager?.onPlaybackRestart();
|
_trackManager?.onPlaybackRestart();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen to position for completion detection (fallback for unreliable MPV events)
|
|
||||||
int? lastObservedPositionMs;
|
int? lastObservedPositionMs;
|
||||||
_positionSubscription = player!.streams.position.listen((position) {
|
_positionSubscription = player!.streams.position.listen((position) {
|
||||||
// Fallback for cases where playbackRestart doesn't fire (observed on
|
// Fallback for cases where playbackRestart doesn't fire (observed on
|
||||||
@@ -2387,10 +2343,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Unregister app lifecycle observer
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|
||||||
// Clean up companion remote playback callbacks
|
|
||||||
_cleanupCompanionRemoteCallbacks();
|
_cleanupCompanionRemoteCallbacks();
|
||||||
|
|
||||||
// Notify Watch Together guests that host is exiting the player
|
// Notify Watch Together guests that host is exiting the player
|
||||||
@@ -2403,10 +2357,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_watchTogetherProvider!.notifyHostExitedPlayer();
|
_watchTogetherProvider!.notifyHostExitedPlayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach from Watch Together session
|
|
||||||
_detachFromWatchTogetherSession();
|
_detachFromWatchTogetherSession();
|
||||||
|
|
||||||
// Dispose value notifiers
|
|
||||||
_isBuffering.dispose();
|
_isBuffering.dispose();
|
||||||
_hasFirstFrame.dispose();
|
_hasFirstFrame.dispose();
|
||||||
_isExiting.dispose();
|
_isExiting.dispose();
|
||||||
@@ -2422,14 +2374,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_sendLiveTimeline('stopped');
|
_sendLiveTimeline('stopped');
|
||||||
_stopLiveTimelineUpdates();
|
_stopLiveTimelineUpdates();
|
||||||
|
|
||||||
// Remove PiP state listener, clear callbacks, disable auto-PiP, and dispose video filter manager
|
|
||||||
_videoPIPManager?.isPipActive.removeListener(_onPipStateChanged);
|
_videoPIPManager?.isPipActive.removeListener(_onPipStateChanged);
|
||||||
_videoPIPManager?.onBeforeEnterPip = null;
|
_videoPIPManager?.onBeforeEnterPip = null;
|
||||||
_videoPIPManager?.disableAutoPip();
|
_videoPIPManager?.disableAutoPip();
|
||||||
PipService.onAutoPipEntering = null;
|
PipService.onAutoPipEntering = null;
|
||||||
_videoFilterManager?.dispose();
|
_videoFilterManager?.dispose();
|
||||||
|
|
||||||
// Release cached scrub-thumbnail data (BIF or trickplay)
|
|
||||||
_scrubPreviewSource?.dispose();
|
_scrubPreviewSource?.dispose();
|
||||||
|
|
||||||
// Mark sleep timer for restart if truly exiting (not episode transition)
|
// Mark sleep timer for restart if truly exiting (not episode transition)
|
||||||
@@ -2437,7 +2387,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
SleepTimerService().markNeedsRestart();
|
SleepTimerService().markNeedsRestart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel stream subscriptions
|
|
||||||
_playingSubscription?.cancel();
|
_playingSubscription?.cancel();
|
||||||
_completedSubscription?.cancel();
|
_completedSubscription?.cancel();
|
||||||
_errorSubscription?.cancel();
|
_errorSubscription?.cancel();
|
||||||
@@ -2455,34 +2404,26 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_mediaControlsSeekableSubscription?.cancel();
|
_mediaControlsSeekableSubscription?.cancel();
|
||||||
_serverStatusSubscription?.cancel();
|
_serverStatusSubscription?.cancel();
|
||||||
|
|
||||||
// Cancel auto-play timer
|
|
||||||
_autoPlayTimer?.cancel();
|
_autoPlayTimer?.cancel();
|
||||||
|
|
||||||
// Cancel still watching timer
|
|
||||||
_stillWatchingTimer?.cancel();
|
_stillWatchingTimer?.cancel();
|
||||||
|
|
||||||
// Dispose Play Next dialog focus nodes
|
|
||||||
_playNextCancelFocusNode.dispose();
|
_playNextCancelFocusNode.dispose();
|
||||||
_playNextConfirmFocusNode.dispose();
|
_playNextConfirmFocusNode.dispose();
|
||||||
|
|
||||||
// Dispose "Still watching?" dialog focus nodes
|
|
||||||
_stillWatchingPauseFocusNode.dispose();
|
_stillWatchingPauseFocusNode.dispose();
|
||||||
_stillWatchingContinueFocusNode.dispose();
|
_stillWatchingContinueFocusNode.dispose();
|
||||||
|
|
||||||
// Dispose screen-level focus node
|
|
||||||
_screenFocusNode.removeListener(_onScreenFocusChanged);
|
_screenFocusNode.removeListener(_onScreenFocusChanged);
|
||||||
_screenFocusNode.dispose();
|
_screenFocusNode.dispose();
|
||||||
|
|
||||||
// Clear media controls and dispose manager
|
|
||||||
_mediaControlsManager?.clear();
|
_mediaControlsManager?.clear();
|
||||||
_mediaControlsManager?.dispose();
|
_mediaControlsManager?.dispose();
|
||||||
|
|
||||||
// Clear Discord Rich Presence + send Trakt stop scrobble
|
|
||||||
DiscordRPCService.instance.stopPlayback();
|
DiscordRPCService.instance.stopPlayback();
|
||||||
TraktScrobbleService.instance.stopPlayback();
|
TraktScrobbleService.instance.stopPlayback();
|
||||||
TrackerCoordinator.instance.stopPlayback();
|
TrackerCoordinator.instance.stopPlayback();
|
||||||
|
|
||||||
// Clean up Windows display mode service
|
|
||||||
if (Platform.isWindows && _displayModeService != null) {
|
if (Platform.isWindows && _displayModeService != null) {
|
||||||
FullscreenStateManager().removeListener(_onFullscreenChanged);
|
FullscreenStateManager().removeListener(_onFullscreenChanged);
|
||||||
}
|
}
|
||||||
@@ -2502,7 +2443,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
player!.abandonAudioFocus();
|
player!.abandonAudioFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable wakelock when leaving the video player
|
|
||||||
_setWakelock(false);
|
_setWakelock(false);
|
||||||
appLogger.d('Wakelock disabled');
|
appLogger.d('Wakelock disabled');
|
||||||
|
|
||||||
@@ -2513,10 +2453,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
// Restore orientation based on cached device type (no context needed)
|
// Restore orientation based on cached device type (no context needed)
|
||||||
try {
|
try {
|
||||||
if (_isPhone) {
|
if (_isPhone) {
|
||||||
// Phone: portrait only
|
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||||
} else {
|
} else {
|
||||||
// Tablet/Desktop: all orientations
|
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.portraitUp,
|
DeviceOrientation.portraitUp,
|
||||||
DeviceOrientation.portraitDown,
|
DeviceOrientation.portraitDown,
|
||||||
@@ -2820,7 +2758,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
bool _isSwitchingChannel = false;
|
bool _isSwitchingChannel = false;
|
||||||
|
|
||||||
/// Switch to an adjacent live TV channel (delta: +1 for next, -1 for previous)
|
|
||||||
/// Start periodic timeline heartbeats for live TV transcode session.
|
/// Start periodic timeline heartbeats for live TV transcode session.
|
||||||
void _startLiveTimelineUpdates() {
|
void _startLiveTimelineUpdates() {
|
||||||
final generation = ++_liveTimelineGeneration;
|
final generation = ++_liveTimelineGeneration;
|
||||||
@@ -3203,8 +3140,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- "Still watching?" prompt --
|
|
||||||
|
|
||||||
void _showStillWatchingDialog() {
|
void _showStillWatchingDialog() {
|
||||||
// Don't show if auto-play dialog is already visible
|
// Don't show if auto-play dialog is already visible
|
||||||
if (_showPlayNextDialog) return;
|
if (_showPlayNextDialog) return;
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
|
|
||||||
typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId});
|
typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId});
|
||||||
|
|
||||||
/// Context for a download that's been enqueued with background_downloader.
|
|
||||||
/// Carries metadata needed between enqueue and completion callback.
|
|
||||||
class _DownloadContext {
|
class _DownloadContext {
|
||||||
final MediaItem metadata;
|
final MediaItem metadata;
|
||||||
final DownloadQueueItem queueItem;
|
final DownloadQueueItem queueItem;
|
||||||
@@ -59,15 +57,12 @@ class DownloadManagerService {
|
|||||||
final DownloadStorageService _storageService;
|
final DownloadStorageService _storageService;
|
||||||
final MediaServerHttpClient _http;
|
final MediaServerHttpClient _http;
|
||||||
|
|
||||||
// Stream controller for download progress updates
|
|
||||||
final _progressController = StreamController<DownloadProgress>.broadcast();
|
final _progressController = StreamController<DownloadProgress>.broadcast();
|
||||||
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
||||||
|
|
||||||
// Stream controller for deletion progress updates
|
|
||||||
final _deletionProgressController = StreamController<DeletionProgress>.broadcast();
|
final _deletionProgressController = StreamController<DeletionProgress>.broadcast();
|
||||||
Stream<DeletionProgress> get deletionProgressStream => _deletionProgressController.stream;
|
Stream<DeletionProgress> get deletionProgressStream => _deletionProgressController.stream;
|
||||||
|
|
||||||
// Context for downloads enqueued in this session
|
|
||||||
final Map<String, _DownloadContext> _pendingDownloadContext = {};
|
final Map<String, _DownloadContext> _pendingDownloadContext = {};
|
||||||
|
|
||||||
// Items recovered with video complete but supplementary downloads missing
|
// Items recovered with video complete but supplementary downloads missing
|
||||||
@@ -80,7 +75,6 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
OfflineModeSource? _offlineSource;
|
OfflineModeSource? _offlineSource;
|
||||||
|
|
||||||
// background_downloader state
|
|
||||||
bool _fileDownloaderInitialized = false;
|
bool _fileDownloaderInitialized = false;
|
||||||
static const _downloadGroup = 'video_downloads';
|
static const _downloadGroup = 'video_downloads';
|
||||||
static const _maxAppRetries = 3;
|
static const _maxAppRetries = 3;
|
||||||
@@ -112,8 +106,6 @@ class DownloadManagerService {
|
|||||||
int _consecutiveQueueFailures = 0;
|
int _consecutiveQueueFailures = 0;
|
||||||
static const _maxConsecutiveFailures = 3;
|
static const _maxConsecutiveFailures = 3;
|
||||||
|
|
||||||
/// Public method to check if downloads should be blocked due to cellular-only setting
|
|
||||||
/// Can be used by DownloadProvider to show user-friendly error
|
|
||||||
static Future<bool> shouldBlockDownloadOnCellular() async {
|
static Future<bool> shouldBlockDownloadOnCellular() async {
|
||||||
final List<ConnectivityResult> connectivity;
|
final List<ConnectivityResult> connectivity;
|
||||||
try {
|
try {
|
||||||
@@ -149,7 +141,6 @@ class DownloadManagerService {
|
|||||||
_storageService = storageService,
|
_storageService = storageService,
|
||||||
_http = http ?? httpClient;
|
_http = http ?? httpClient;
|
||||||
|
|
||||||
/// Register a callback to resolve the correct [MediaServerClient] for a given serverId.
|
|
||||||
void setClientResolver(MediaClientResolver resolver) {
|
void setClientResolver(MediaClientResolver resolver) {
|
||||||
_clientResolver = resolver;
|
_clientResolver = resolver;
|
||||||
}
|
}
|
||||||
@@ -383,10 +374,9 @@ class DownloadManagerService {
|
|||||||
progressBar: true,
|
progressBar: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Configure native holding queue: max 1 concurrent (Plex server limitation)
|
// Plex servers can reject concurrent media downloads.
|
||||||
await FileDownloader().configure(globalConfig: (Config.holdingQueue, (1, 1, 1)));
|
await FileDownloader().configure(globalConfig: (Config.holdingQueue, (1, 1, 1)));
|
||||||
|
|
||||||
// Track tasks for persistence across app restarts
|
|
||||||
await FileDownloader().trackTasks();
|
await FileDownloader().trackTasks();
|
||||||
// Deliver status updates from iOS background-to-foreground transitions
|
// Deliver status updates from iOS background-to-foreground transitions
|
||||||
await FileDownloader().resumeFromBackground();
|
await FileDownloader().resumeFromBackground();
|
||||||
@@ -679,7 +669,6 @@ class DownloadManagerService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue a download for a media item
|
|
||||||
Future<void> queueDownload({
|
Future<void> queueDownload({
|
||||||
required MediaItem metadata,
|
required MediaItem metadata,
|
||||||
required MediaServerClient client,
|
required MediaServerClient client,
|
||||||
@@ -690,7 +679,6 @@ class DownloadManagerService {
|
|||||||
}) async {
|
}) async {
|
||||||
final globalKey = metadata.globalKey;
|
final globalKey = metadata.globalKey;
|
||||||
|
|
||||||
// Check if already downloading or completed
|
|
||||||
final existing = await _database.getDownloadedMedia(globalKey);
|
final existing = await _database.getDownloadedMedia(globalKey);
|
||||||
if (existing != null &&
|
if (existing != null &&
|
||||||
(existing.status == DownloadStatus.downloading.index || existing.status == DownloadStatus.completed.index)) {
|
(existing.status == DownloadStatus.downloading.index || existing.status == DownloadStatus.completed.index)) {
|
||||||
@@ -698,7 +686,6 @@ class DownloadManagerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert into database
|
|
||||||
await _database.insertDownload(
|
await _database.insertDownload(
|
||||||
serverId: metadata.serverId!,
|
serverId: metadata.serverId!,
|
||||||
clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId,
|
clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId,
|
||||||
@@ -716,7 +703,6 @@ class DownloadManagerService {
|
|||||||
// cache is warm and falls back to the existing entry on network error.
|
// cache is warm and falls back to the existing entry on network error.
|
||||||
await _pinMetadataForOffline(client, metadata);
|
await _pinMetadataForOffline(client, metadata);
|
||||||
|
|
||||||
// Add to queue
|
|
||||||
await _database.addToQueue(
|
await _database.addToQueue(
|
||||||
mediaGlobalKey: globalKey,
|
mediaGlobalKey: globalKey,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
@@ -726,7 +712,6 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
||||||
|
|
||||||
// Start processing if not already
|
|
||||||
unawaited(_processQueue(client));
|
unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1219,7 +1204,6 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store video path in DB
|
|
||||||
await _database.updateVideoFilePath(globalKey, storedPath);
|
await _database.updateVideoFilePath(globalKey, storedPath);
|
||||||
appLogger.d('Video download completed for $globalKey');
|
appLogger.d('Video download completed for $globalKey');
|
||||||
|
|
||||||
@@ -1229,7 +1213,6 @@ class DownloadManagerService {
|
|||||||
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
||||||
final showYear = ctx?.showYear;
|
final showYear = ctx?.showYear;
|
||||||
|
|
||||||
// Get queue item settings (still in drift at this point)
|
|
||||||
final queueItem =
|
final queueItem =
|
||||||
ctx?.queueItem ??
|
ctx?.queueItem ??
|
||||||
await (_database.select(
|
await (_database.select(
|
||||||
@@ -1278,7 +1261,6 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve metadata from cache using a globalKey
|
|
||||||
Future<MediaItem?> _resolveMetadata(String globalKey) async {
|
Future<MediaItem?> _resolveMetadata(String globalKey) async {
|
||||||
final parsed = parseGlobalKey(globalKey);
|
final parsed = parseGlobalKey(globalKey);
|
||||||
if (parsed == null) return null;
|
if (parsed == null) return null;
|
||||||
@@ -1292,7 +1274,6 @@ class DownloadManagerService {
|
|||||||
return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year;
|
return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-derive the SAF file URI from metadata (for recovery when context is lost)
|
|
||||||
Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear) async {
|
Future<String?> _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear) async {
|
||||||
final safBaseUri = _storageService.safBaseUri;
|
final safBaseUri = _storageService.safBaseUri;
|
||||||
if (safBaseUri == null) return null;
|
if (safBaseUri == null) return null;
|
||||||
@@ -1334,8 +1315,6 @@ class DownloadManagerService {
|
|||||||
return _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: clientScopeId);
|
return _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: clientScopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download artwork for a media item using hash-based storage
|
|
||||||
/// Downloads all artwork types: thumb/poster, clearLogo, and background art
|
|
||||||
Future<void> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
Future<void> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
||||||
if (metadata.serverId == null) return;
|
if (metadata.serverId == null) return;
|
||||||
|
|
||||||
@@ -1348,7 +1327,6 @@ class DownloadManagerService {
|
|||||||
await _downloadSingleArtwork(serverId, spec);
|
await _downloadSingleArtwork(serverId, spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store thumb reference in database (primary artwork for display)
|
|
||||||
final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!);
|
final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!);
|
||||||
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath);
|
await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath);
|
||||||
|
|
||||||
@@ -1605,17 +1583,14 @@ class DownloadManagerService {
|
|||||||
await _database.removeFromQueue(globalKey);
|
await _database.removeFromQueue(globalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a downloaded item and its files
|
|
||||||
Future<void> deleteDownload(String globalKey) async {
|
Future<void> deleteDownload(String globalKey) async {
|
||||||
_cancelDownloadTimers(globalKey);
|
_cancelDownloadTimers(globalKey);
|
||||||
// Cancel if actively downloading via background_downloader
|
|
||||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||||
if (bgTaskId != null) {
|
if (bgTaskId != null) {
|
||||||
await FileDownloader().cancelTaskWithId(bgTaskId);
|
await FileDownloader().cancelTaskWithId(bgTaskId);
|
||||||
}
|
}
|
||||||
_pendingDownloadContext.remove(globalKey);
|
_pendingDownloadContext.remove(globalKey);
|
||||||
|
|
||||||
// Delete files from storage
|
|
||||||
final parsed = parseGlobalKey(globalKey);
|
final parsed = parseGlobalKey(globalKey);
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
await _database.deleteDownload(globalKey);
|
await _database.deleteDownload(globalKey);
|
||||||
@@ -1636,24 +1611,18 @@ class DownloadManagerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine total items to delete
|
|
||||||
final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId);
|
final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId);
|
||||||
|
|
||||||
// Emit initial progress
|
|
||||||
_emitDeletionProgress(
|
_emitDeletionProgress(
|
||||||
DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems),
|
DeletionProgress(globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete files from storage (with progress updates)
|
|
||||||
await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId);
|
||||||
|
|
||||||
// Delete from API cache
|
|
||||||
await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId);
|
await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId);
|
||||||
|
|
||||||
// Delete from database
|
|
||||||
await _database.deleteDownload(globalKey);
|
await _database.deleteDownload(globalKey);
|
||||||
|
|
||||||
// Emit completion
|
|
||||||
_emitDeletionProgress(
|
_emitDeletionProgress(
|
||||||
DeletionProgress(
|
DeletionProgress(
|
||||||
globalKey: globalKey,
|
globalKey: globalKey,
|
||||||
@@ -1664,7 +1633,6 @@ class DownloadManagerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit deletion progress update
|
|
||||||
void _emitDeletionProgress(DeletionProgress progress) {
|
void _emitDeletionProgress(DeletionProgress progress) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_deletionProgressController.add(progress);
|
_deletionProgressController.add(progress);
|
||||||
@@ -1687,12 +1655,10 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete media files using metadata to find correct paths
|
|
||||||
Future<void> _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async {
|
Future<void> _deleteMediaFilesWithMetadata(String serverId, String ratingKey, {String? clientScopeId}) async {
|
||||||
try {
|
try {
|
||||||
final gk = buildGlobalKey(serverId, ratingKey);
|
final gk = buildGlobalKey(serverId, ratingKey);
|
||||||
final downloadRecord = await _database.getDownloadedMedia(gk);
|
final downloadRecord = await _database.getDownloadedMedia(gk);
|
||||||
// Get metadata from API cache
|
|
||||||
final scopeId = clientScopeId ?? downloadRecord?.clientScopeId;
|
final scopeId = clientScopeId ?? downloadRecord?.clientScopeId;
|
||||||
final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId);
|
final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId);
|
||||||
|
|
||||||
@@ -1777,7 +1743,6 @@ class DownloadManagerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a set of all chapter thumb paths used by OTHER items on this server
|
|
||||||
final otherItems = await _database.getDownloadsByServerId(serverId);
|
final otherItems = await _database.getDownloadsByServerId(serverId);
|
||||||
final inUseThumbPaths = <String>{};
|
final inUseThumbPaths = <String>{};
|
||||||
for (final item in otherItems) {
|
for (final item in otherItems) {
|
||||||
@@ -1801,7 +1766,6 @@ class DownloadManagerService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get artwork file path and delete
|
|
||||||
final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath);
|
final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath);
|
||||||
if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) {
|
if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
@@ -1819,7 +1783,6 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete episode files
|
|
||||||
Future<void> _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async {
|
Future<void> _deleteEpisodeFiles(MediaItem episode, String serverId, {String? clientScopeId}) async {
|
||||||
try {
|
try {
|
||||||
final parentMetadata = episode.grandparentId != null
|
final parentMetadata = episode.grandparentId != null
|
||||||
@@ -1827,7 +1790,6 @@ class DownloadManagerService {
|
|||||||
: null;
|
: null;
|
||||||
final showYear = parentMetadata?.year;
|
final showYear = parentMetadata?.year;
|
||||||
|
|
||||||
// Delete video file
|
|
||||||
final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear);
|
final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear);
|
||||||
final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.'));
|
final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.'));
|
||||||
final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt);
|
final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt);
|
||||||
@@ -1837,21 +1799,17 @@ class DownloadManagerService {
|
|||||||
await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download');
|
await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete thumbnail
|
|
||||||
final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear);
|
final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear);
|
||||||
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
|
await _deleteFileIfExists(File(thumbPath), 'episode thumbnail');
|
||||||
|
|
||||||
// Delete subtitles directory
|
|
||||||
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
||||||
if (await subsDir.exists()) {
|
if (await subsDir.exists()) {
|
||||||
await subsDir.delete(recursive: true);
|
await subsDir.delete(recursive: true);
|
||||||
appLogger.i('Deleted episode subtitles: ${subsDir.path}');
|
appLogger.i('Deleted episode subtitles: ${subsDir.path}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete chapter thumbnails (with reference counting)
|
|
||||||
await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId);
|
await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId);
|
||||||
|
|
||||||
// Clean up parent directories if empty
|
|
||||||
await _cleanupEmptyDirectories(episode, showYear);
|
await _cleanupEmptyDirectories(episode, showYear);
|
||||||
|
|
||||||
// Safety net: verify the actual DB-recorded file is gone
|
// Safety net: verify the actual DB-recorded file is gone
|
||||||
@@ -1861,7 +1819,6 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete season files
|
|
||||||
Future<void> _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async {
|
Future<void> _deleteSeasonFiles(MediaItem season, String serverId, {String? clientScopeId}) async {
|
||||||
try {
|
try {
|
||||||
final parentMetadata = season.parentId != null
|
final parentMetadata = season.parentId != null
|
||||||
@@ -1869,7 +1826,6 @@ class DownloadManagerService {
|
|||||||
: null;
|
: null;
|
||||||
final showYear = parentMetadata?.year;
|
final showYear = parentMetadata?.year;
|
||||||
|
|
||||||
// Get all episodes in this season
|
|
||||||
final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId);
|
final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId);
|
||||||
|
|
||||||
appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}');
|
appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}');
|
||||||
@@ -1946,10 +1902,8 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete show files
|
|
||||||
Future<void> _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async {
|
Future<void> _deleteShowFiles(MediaItem show, String serverId, {String? clientScopeId}) async {
|
||||||
try {
|
try {
|
||||||
// Get all episodes in this show
|
|
||||||
final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId);
|
final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId);
|
||||||
|
|
||||||
appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}');
|
appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}');
|
||||||
@@ -1971,7 +1925,6 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete movie files
|
|
||||||
Future<void> _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async {
|
Future<void> _deleteMovieFiles(MediaItem movie, String serverId, {String? clientScopeId}) async {
|
||||||
try {
|
try {
|
||||||
final movieDir = await _storageService.getMovieDirectory(movie);
|
final movieDir = await _storageService.getMovieDirectory(movie);
|
||||||
@@ -1980,7 +1933,6 @@ class DownloadManagerService {
|
|||||||
appLogger.i('Deleted movie directory: ${movieDir.path}');
|
appLogger.i('Deleted movie directory: ${movieDir.path}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete chapter thumbnails (with reference counting)
|
|
||||||
await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId);
|
await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId);
|
||||||
|
|
||||||
// Safety net: verify the actual DB-recorded file is gone
|
// Safety net: verify the actual DB-recorded file is gone
|
||||||
@@ -2161,7 +2113,6 @@ class DownloadManagerService {
|
|||||||
appLogger.w('Safety net: video still exists after metadata deletion, deleting: $videoPath');
|
appLogger.w('Safety net: video still exists after metadata deletion, deleting: $videoPath');
|
||||||
await videoFile.delete();
|
await videoFile.delete();
|
||||||
|
|
||||||
// Clean up .part file and subtitles directory alongside video
|
|
||||||
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
||||||
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
||||||
final subsDir = Directory(subsPath);
|
final subsDir = Directory(subsPath);
|
||||||
@@ -2238,29 +2189,24 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if season artwork is in use
|
|
||||||
Future<bool> _isSeasonArtworkInUse(MediaItem episode, int? _) async {
|
Future<bool> _isSeasonArtworkInUse(MediaItem episode, int? _) async {
|
||||||
final seasonKey = episode.parentId;
|
final seasonKey = episode.parentId;
|
||||||
if (seasonKey == null) return false;
|
if (seasonKey == null) return false;
|
||||||
|
|
||||||
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
|
final otherEpisodes = await _database.getEpisodesBySeason(seasonKey);
|
||||||
|
|
||||||
// Check if any episodes besides this one
|
|
||||||
return otherEpisodes.any((e) => e.globalKey != episode.globalKey);
|
return otherEpisodes.any((e) => e.globalKey != episode.globalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if show artwork is in use
|
|
||||||
Future<bool> _isShowArtworkInUse(MediaItem metadata, int? _) async {
|
Future<bool> _isShowArtworkInUse(MediaItem metadata, int? _) async {
|
||||||
final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id;
|
final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id;
|
||||||
|
|
||||||
// Use targeted query instead of full table scan
|
// Use targeted query instead of full table scan
|
||||||
final showEpisodes = await _database.getEpisodesByShow(showKey);
|
final showEpisodes = await _database.getEpisodesByShow(showKey);
|
||||||
|
|
||||||
// Check if any episodes belong to this show besides the current item
|
|
||||||
return showEpisodes.any((item) => item.globalKey != metadata.globalKey);
|
return showEpisodes.any((item) => item.globalKey != metadata.globalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find file with any extension
|
|
||||||
Future<File?> _findFileWithAnyExtension(String pathWithoutExt) async {
|
Future<File?> _findFileWithAnyExtension(String pathWithoutExt) async {
|
||||||
final dir = Directory(path.dirname(pathWithoutExt));
|
final dir = Directory(path.dirname(pathWithoutExt));
|
||||||
final baseName = path.basename(pathWithoutExt);
|
final baseName = path.basename(pathWithoutExt);
|
||||||
@@ -2293,10 +2239,8 @@ class DownloadManagerService {
|
|||||||
final videoDeleted = await _deleteFileIfExists(videoFile, 'video file');
|
final videoDeleted = await _deleteFileIfExists(videoFile, 'video file');
|
||||||
|
|
||||||
if (videoDeleted) {
|
if (videoDeleted) {
|
||||||
// Delete .part file from interrupted downloads
|
|
||||||
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
await _deleteFileIfExists(File('$videoPath.part'), 'partial download');
|
||||||
|
|
||||||
// Delete subtitle directory
|
|
||||||
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs');
|
||||||
final subsDir = Directory(subsPath);
|
final subsDir = Directory(subsPath);
|
||||||
if (await subsDir.exists()) {
|
if (await subsDir.exists()) {
|
||||||
@@ -2304,7 +2248,6 @@ class DownloadManagerService {
|
|||||||
appLogger.i('Deleted subtitles: $subsPath');
|
appLogger.i('Deleted subtitles: $subsPath');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty parent directories
|
|
||||||
await _cleanupEmptyParentDirectories(videoFile.parent);
|
await _cleanupEmptyParentDirectories(videoFile.parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2324,12 +2267,10 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all downloaded media items (for loading persisted data)
|
|
||||||
Future<List<DownloadedMediaItem>> getAllDownloads() {
|
Future<List<DownloadedMediaItem>> getAllDownloads() {
|
||||||
return _database.select(_database.downloadedMedia).get();
|
return _database.select(_database.downloadedMedia).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific downloaded media item by globalKey
|
|
||||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||||
return _database.getDownloadedMedia(globalKey);
|
return _database.getDownloadedMedia(globalKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,34 +38,26 @@ class DownloadStorageService {
|
|||||||
Directory? _baseDownloadsDir;
|
Directory? _baseDownloadsDir;
|
||||||
String? _artworkDirectoryPath;
|
String? _artworkDirectoryPath;
|
||||||
|
|
||||||
// Custom path configuration
|
|
||||||
SettingsService? _settingsService;
|
SettingsService? _settingsService;
|
||||||
String? _customDownloadPath;
|
String? _customDownloadPath;
|
||||||
String _customPathType = 'file';
|
String _customPathType = 'file';
|
||||||
|
|
||||||
/// Check if currently using SAF mode (Android only)
|
|
||||||
bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null;
|
bool get isUsingSaf => Platform.isAndroid && _customPathType == 'saf' && _customDownloadPath != null;
|
||||||
|
|
||||||
/// Get the SAF base URI (only valid when isUsingSaf is true)
|
|
||||||
String? get safBaseUri => isUsingSaf ? _customDownloadPath : null;
|
String? get safBaseUri => isUsingSaf ? _customDownloadPath : null;
|
||||||
|
|
||||||
/// Get artwork directory path (cached, synchronous after first call)
|
|
||||||
String? get artworkDirectoryPath => _artworkDirectoryPath;
|
String? get artworkDirectoryPath => _artworkDirectoryPath;
|
||||||
|
|
||||||
/// Initialize with settings service (call during app startup)
|
|
||||||
Future<void> initialize(SettingsService settingsService) async {
|
Future<void> initialize(SettingsService settingsService) async {
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_customDownloadPath = settingsService.read(SettingsService.customDownloadPath);
|
_customDownloadPath = settingsService.read(SettingsService.customDownloadPath);
|
||||||
_customPathType = settingsService.read(SettingsService.customDownloadPathType) ?? 'file';
|
_customPathType = settingsService.read(SettingsService.customDownloadPathType) ?? 'file';
|
||||||
// Reset cached directories to force recalculation
|
|
||||||
_baseDownloadsDir = null;
|
_baseDownloadsDir = null;
|
||||||
_artworkDirectoryPath = null;
|
_artworkDirectoryPath = null;
|
||||||
|
|
||||||
// Eagerly initialize artwork directory for sync access
|
|
||||||
await getArtworkDirectory();
|
await getArtworkDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh custom path from settings (call when settings change)
|
|
||||||
Future<void> refreshCustomPath() async {
|
Future<void> refreshCustomPath() async {
|
||||||
if (_settingsService != null) {
|
if (_settingsService != null) {
|
||||||
_customDownloadPath = _settingsService!.read(SettingsService.customDownloadPath);
|
_customDownloadPath = _settingsService!.read(SettingsService.customDownloadPath);
|
||||||
@@ -93,10 +85,8 @@ class DownloadStorageService {
|
|||||||
return 'S${season}E$ep - $episodeName';
|
return 'S${season}E$ep - $episodeName';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if using custom download path
|
|
||||||
bool isUsingCustomPath() => _customDownloadPath != null;
|
bool isUsingCustomPath() => _customDownloadPath != null;
|
||||||
|
|
||||||
/// Get current download path for display in settings
|
|
||||||
Future<String> getCurrentDownloadPathDisplay() async {
|
Future<String> getCurrentDownloadPathDisplay() async {
|
||||||
if (_customDownloadPath != null) {
|
if (_customDownloadPath != null) {
|
||||||
return _customDownloadPath!;
|
return _customDownloadPath!;
|
||||||
@@ -105,7 +95,6 @@ class DownloadStorageService {
|
|||||||
return dir.path;
|
return dir.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a directory is writable
|
|
||||||
Future<bool> isDirectoryWritable(Directory dir) async {
|
Future<bool> isDirectoryWritable(Directory dir) async {
|
||||||
try {
|
try {
|
||||||
if (!await dir.exists()) {
|
if (!await dir.exists()) {
|
||||||
@@ -121,11 +110,9 @@ class DownloadStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize and get base downloads directory
|
|
||||||
Future<Directory> getDownloadsDirectory() async {
|
Future<Directory> getDownloadsDirectory() async {
|
||||||
if (_baseDownloadsDir != null) return _baseDownloadsDir!;
|
if (_baseDownloadsDir != null) return _baseDownloadsDir!;
|
||||||
|
|
||||||
// Check for custom path first (file type only - SAF handled differently)
|
|
||||||
if (_customDownloadPath != null && _customPathType == 'file') {
|
if (_customDownloadPath != null && _customPathType == 'file') {
|
||||||
final customDir = Directory(_customDownloadPath!);
|
final customDir = Directory(_customDownloadPath!);
|
||||||
if (await isDirectoryWritable(customDir)) {
|
if (await isDirectoryWritable(customDir)) {
|
||||||
@@ -135,7 +122,6 @@ class DownloadStorageService {
|
|||||||
// Fall through to default if custom path is not writable
|
// Fall through to default if custom path is not writable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default path logic
|
|
||||||
final baseDir = await _getBaseAppDir();
|
final baseDir = await _getBaseAppDir();
|
||||||
_baseDownloadsDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'downloads')));
|
_baseDownloadsDir = await _ensureDirectoryExists(Directory(path.join(baseDir.path, 'downloads')));
|
||||||
return _baseDownloadsDir!;
|
return _baseDownloadsDir!;
|
||||||
@@ -176,7 +162,6 @@ class DownloadStorageService {
|
|||||||
/// Example: artwork/a1b2c3d4e5f6.jpg
|
/// Example: artwork/a1b2c3d4e5f6.jpg
|
||||||
String? getArtworkPathSync(String serverId, String thumbPath) {
|
String? getArtworkPathSync(String serverId, String thumbPath) {
|
||||||
if (_artworkDirectoryPath == null) return null;
|
if (_artworkDirectoryPath == null) return null;
|
||||||
// Create hash from serverId:thumbPath for deduplication
|
|
||||||
final hash = _hashArtworkPath(serverId, thumbPath);
|
final hash = _hashArtworkPath(serverId, thumbPath);
|
||||||
return path.join(_artworkDirectoryPath!, '$hash.jpg');
|
return path.join(_artworkDirectoryPath!, '$hash.jpg');
|
||||||
}
|
}
|
||||||
@@ -189,7 +174,6 @@ class DownloadStorageService {
|
|||||||
return path.join(artworkDir.path, '$hash.jpg');
|
return path.join(artworkDir.path, '$hash.jpg');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if artwork already exists (for deduplication)
|
|
||||||
Future<bool> artworkExists(String serverId, String thumbPath) async {
|
Future<bool> artworkExists(String serverId, String thumbPath) async {
|
||||||
final artworkPath = await getArtworkPathFromThumb(serverId, thumbPath);
|
final artworkPath = await getArtworkPathFromThumb(serverId, thumbPath);
|
||||||
return File(artworkPath).exists();
|
return File(artworkPath).exists();
|
||||||
@@ -201,19 +185,16 @@ class DownloadStorageService {
|
|||||||
return md5.convert(utf8.encode(combined)).toString();
|
return md5.convert(utf8.encode(combined)).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get directory for a specific media item
|
|
||||||
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
|
Future<Directory> getMediaDirectory(String serverId, String ratingKey) async {
|
||||||
final baseDir = await getDownloadsDirectory();
|
final baseDir = await getDownloadsDirectory();
|
||||||
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
|
return _ensureDirectoryExists(Directory(path.join(baseDir.path, serverId, ratingKey)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get video file path
|
|
||||||
Future<String> getVideoFilePath(String serverId, String ratingKey, String extension) async {
|
Future<String> getVideoFilePath(String serverId, String ratingKey, String extension) async {
|
||||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||||
return path.join(mediaDir.path, 'video.$extension');
|
return path.join(mediaDir.path, 'video.$extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get subtitles directory
|
|
||||||
Future<Directory> getSubtitlesDirectory(String serverId, String ratingKey) async {
|
Future<Directory> getSubtitlesDirectory(String serverId, String ratingKey) async {
|
||||||
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
final mediaDir = await getMediaDirectory(serverId, ratingKey);
|
||||||
final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles'));
|
final subtitlesDir = Directory(path.join(mediaDir.path, 'subtitles'));
|
||||||
@@ -223,16 +204,11 @@ class DownloadStorageService {
|
|||||||
return subtitlesDir;
|
return subtitlesDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get subtitle file path
|
|
||||||
Future<String> getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async {
|
Future<String> getSubtitlePath(String serverId, String ratingKey, int trackId, String extension) async {
|
||||||
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
|
final subtitlesDir = await getSubtitlesDirectory(serverId, ratingKey);
|
||||||
return path.join(subtitlesDir.path, '$trackId.$extension');
|
return path.join(subtitlesDir.path, '$trackId.$extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// USER-FRIENDLY PATH METHODS (for Files app visibility)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// Sanitize a filename by removing invalid filesystem characters
|
/// Sanitize a filename by removing invalid filesystem characters
|
||||||
String _sanitizeFileName(String name) {
|
String _sanitizeFileName(String name) {
|
||||||
// Remove invalid filesystem characters: < > : " / \ | ? *
|
// Remove invalid filesystem characters: < > : " / \ | ? *
|
||||||
@@ -263,7 +239,6 @@ class DownloadStorageService {
|
|||||||
return year != null ? '$sanitized ($year)' : sanitized;
|
return year != null ? '$sanitized ($year)' : sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the folder name for a movie: "Movie Name (YYYY)"
|
|
||||||
String _getMovieFolderName(MediaItem movie) {
|
String _getMovieFolderName(MediaItem movie) {
|
||||||
return _formatTitleWithYear(movie.title!, movie.year);
|
return _formatTitleWithYear(movie.title!, movie.year);
|
||||||
}
|
}
|
||||||
@@ -276,7 +251,6 @@ class DownloadStorageService {
|
|||||||
return _formatTitleWithYear(title, year);
|
return _formatTitleWithYear(title, year);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get movie directory: downloads/Movies/{Movie Name} ({Year})/
|
|
||||||
Future<Directory> getMovieDirectory(MediaItem movie) async {
|
Future<Directory> getMovieDirectory(MediaItem movie) async {
|
||||||
final baseDir = await getDownloadsDirectory();
|
final baseDir = await getDownloadsDirectory();
|
||||||
final movieFolder = _getMovieFolderName(movie);
|
final movieFolder = _getMovieFolderName(movie);
|
||||||
@@ -336,21 +310,18 @@ class DownloadStorageService {
|
|||||||
return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')));
|
return _ensureDirectoryExists(Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get episode subtitle path
|
|
||||||
/// [showYear]: Pass the show's premiere year (not episode year)
|
/// [showYear]: Pass the show's premiere year (not episode year)
|
||||||
Future<String> getEpisodeSubtitlePath(MediaItem episode, int trackId, String extension, {int? showYear}) async {
|
Future<String> getEpisodeSubtitlePath(MediaItem episode, int trackId, String extension, {int? showYear}) async {
|
||||||
final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
final subsDir = await getEpisodeSubtitlesDirectory(episode, showYear: showYear);
|
||||||
return path.join(subsDir.path, '$trackId.$extension');
|
return path.join(subsDir.path, '$trackId.$extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get subtitles directory for movie
|
|
||||||
Future<Directory> getMovieSubtitlesDirectory(MediaItem movie) async {
|
Future<Directory> getMovieSubtitlesDirectory(MediaItem movie) async {
|
||||||
final movieDir = await getMovieDirectory(movie);
|
final movieDir = await getMovieDirectory(movie);
|
||||||
final baseName = _getMovieFolderName(movie);
|
final baseName = _getMovieFolderName(movie);
|
||||||
return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs')));
|
return _ensureDirectoryExists(Directory(path.join(movieDir.path, '${baseName}_subs')));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get movie subtitle path
|
|
||||||
Future<String> getMovieSubtitlePath(MediaItem movie, int trackId, String extension) async {
|
Future<String> getMovieSubtitlePath(MediaItem movie, int trackId, String extension) async {
|
||||||
final subsDir = await getMovieSubtitlesDirectory(movie);
|
final subsDir = await getMovieSubtitlesDirectory(movie);
|
||||||
return path.join(subsDir.path, '$trackId.$extension');
|
return path.join(subsDir.path, '$trackId.$extension');
|
||||||
@@ -375,14 +346,12 @@ class DownloadStorageService {
|
|||||||
}
|
}
|
||||||
if (result != absolutePath) return result;
|
if (result != absolutePath) return result;
|
||||||
|
|
||||||
// Already relative or from a different base - return as-is
|
|
||||||
return absolutePath;
|
return absolutePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a relative file path to an absolute path (for file operations)
|
/// Convert a relative file path to an absolute path (for file operations)
|
||||||
/// Reconstructs the full path using the current app documents directory.
|
/// Reconstructs the full path using the current app documents directory.
|
||||||
Future<String> toAbsolutePath(String relativePath) async {
|
Future<String> toAbsolutePath(String relativePath) async {
|
||||||
// If it's already an absolute path, return as-is
|
|
||||||
if (path.isAbsolute(relativePath)) {
|
if (path.isAbsolute(relativePath)) {
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
@@ -463,13 +432,10 @@ class DownloadStorageService {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get path components for SAF based on media type
|
|
||||||
/// Returns list of directory names to create under the SAF base
|
|
||||||
List<String> getMovieSafPathComponents(MediaItem movie) {
|
List<String> getMovieSafPathComponents(MediaItem movie) {
|
||||||
return ['Movies', _getMovieFolderName(movie)];
|
return ['Movies', _getMovieFolderName(movie)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get path components for episode SAF storage
|
|
||||||
List<String> getEpisodeSafPathComponents(MediaItem episode, {int? showYear}) {
|
List<String> getEpisodeSafPathComponents(MediaItem episode, {int? showYear}) {
|
||||||
final showFolder = _getShowFolderName(episode, showYear: showYear);
|
final showFolder = _getShowFolderName(episode, showYear: showYear);
|
||||||
final seasonNum = padNumber(episode.parentIndex ?? 0, 2);
|
final seasonNum = padNumber(episode.parentIndex ?? 0, 2);
|
||||||
@@ -489,12 +455,10 @@ class DownloadStorageService {
|
|||||||
return ['TV Shows', showFolder, 'Season $seasonNum'];
|
return ['TV Shows', showFolder, 'Season $seasonNum'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get SAF file name for a movie
|
|
||||||
String getMovieSafFileName(MediaItem movie, String extension) {
|
String getMovieSafFileName(MediaItem movie, String extension) {
|
||||||
return '${_getMovieFolderName(movie)}.$extension';
|
return '${_getMovieFolderName(movie)}.$extension';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get SAF file name for an episode
|
|
||||||
String getEpisodeSafFileName(MediaItem episode, String extension) {
|
String getEpisodeSafFileName(MediaItem episode, String extension) {
|
||||||
final fileName = _formatEpisodeFileName(episode);
|
final fileName = _formatEpisodeFileName(episode);
|
||||||
return '$fileName.$extension';
|
return '$fileName.$extension';
|
||||||
@@ -503,7 +467,6 @@ class DownloadStorageService {
|
|||||||
/// Get the extension-less episode filename used for SAF lookups.
|
/// Get the extension-less episode filename used for SAF lookups.
|
||||||
String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode);
|
String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode);
|
||||||
|
|
||||||
/// Check if a path is a SAF content URI
|
|
||||||
bool isSafUri(String storedPath) {
|
bool isSafUri(String storedPath) {
|
||||||
return storedPath.startsWith('content://');
|
return storedPath.startsWith('content://');
|
||||||
}
|
}
|
||||||
@@ -513,7 +476,6 @@ class DownloadStorageService {
|
|||||||
/// For file paths, ensures the path is absolute
|
/// For file paths, ensures the path is absolute
|
||||||
Future<String> getReadablePath(String storedPath) async {
|
Future<String> getReadablePath(String storedPath) async {
|
||||||
if (isSafUri(storedPath)) {
|
if (isSafUri(storedPath)) {
|
||||||
// SAF content:// URIs are already readable by media players
|
|
||||||
return storedPath;
|
return storedPath;
|
||||||
}
|
}
|
||||||
return await ensureAbsolutePath(storedPath);
|
return await ensureAbsolutePath(storedPath);
|
||||||
|
|||||||
@@ -372,7 +372,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse libraries from the library provider
|
|
||||||
final libraries = <PlexLibraryDto>[];
|
final libraries = <PlexLibraryDto>[];
|
||||||
final epg = <({String identifier, String gridEndpoint})>[];
|
final epg = <({String identifier, String gridEndpoint})>[];
|
||||||
|
|
||||||
@@ -554,11 +553,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return ConnectionTestResult(success: true, latencyMs: avgLatency);
|
return ConnectionTestResult(success: true, latencyMs: avgLatency);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API Response Parsing Helpers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Extract MediaContainer from API response
|
|
||||||
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
|
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
|
||||||
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
||||||
return response.data['MediaContainer'];
|
return response.data['MediaContainer'];
|
||||||
@@ -566,15 +560,11 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag a PlexMetadataDto with this client's serverId and serverName
|
|
||||||
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
|
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
|
||||||
metadata.copyWith(serverId: serverId, serverName: serverName);
|
metadata.copyWith(serverId: serverId, serverName: serverName);
|
||||||
|
|
||||||
/// Create and tag a PlexMetadataDto from JSON
|
|
||||||
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
|
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
|
||||||
|
|
||||||
/// Extract list of PlexMetadataDto from response
|
|
||||||
/// Automatically tags all items with this client's serverId and serverName
|
|
||||||
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) {
|
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) {
|
||||||
final container = _getMediaContainer(response);
|
final container = _getMediaContainer(response);
|
||||||
if (container != null && container['Metadata'] != null) {
|
if (container != null && container['Metadata'] != null) {
|
||||||
@@ -583,7 +573,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract first metadata JSON from response (returns raw Map or null)
|
|
||||||
Map<String, dynamic>? _getFirstMetadataJson(MediaServerResponse response) {
|
Map<String, dynamic>? _getFirstMetadataJson(MediaServerResponse response) {
|
||||||
final container = _getMediaContainer(response);
|
final container = _getMediaContainer(response);
|
||||||
if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) {
|
if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) {
|
||||||
@@ -592,7 +581,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generic helper to extract and map Directory list from response
|
|
||||||
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
|
List<T> _extractDirectoryList<T>(MediaServerResponse response, T Function(Map<String, dynamic>) fromJson) {
|
||||||
final container = _getMediaContainer(response);
|
final container = _getMediaContainer(response);
|
||||||
if (container != null && container['Directory'] != null) {
|
if (container != null && container['Directory'] != null) {
|
||||||
@@ -601,7 +589,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract PlexLibraryDto list from response with auto-tagging
|
|
||||||
List<PlexLibraryDto> _extractLibraryList(MediaServerResponse response) {
|
List<PlexLibraryDto> _extractLibraryList(MediaServerResponse response) {
|
||||||
final container = _getMediaContainer(response);
|
final container = _getMediaContainer(response);
|
||||||
if (container != null && container['Directory'] != null) {
|
if (container != null && container['Directory'] != null) {
|
||||||
@@ -616,7 +603,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract PlexPlaylistDto list from response with auto-tagging
|
|
||||||
List<PlexPlaylistDto> _extractPlaylistList(MediaServerResponse response) {
|
List<PlexPlaylistDto> _extractPlaylistList(MediaServerResponse response) {
|
||||||
final container = _getMediaContainer(response);
|
final container = _getMediaContainer(response);
|
||||||
if (container != null && container['Metadata'] != null) {
|
if (container != null && container['Metadata'] != null) {
|
||||||
@@ -631,11 +617,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// API Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get server identity
|
|
||||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||||
final response = await _getWithFailover('/identity');
|
final response = await _getWithFailover('/identity');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -981,8 +962,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
'Failed to select streams',
|
'Failed to select streams',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
|
|
||||||
// Ici, on retourne true par défaut si rien n'est fait
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,13 +1235,10 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) {
|
if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) {
|
||||||
final mediaList = metadataJson['Media'] as List;
|
final mediaList = metadataJson['Media'] as List;
|
||||||
|
|
||||||
// Parse available media versions first (convert via the internal
|
|
||||||
// mapper so PlaybackInitializationResult sees neutral MediaVersion).
|
|
||||||
availableVersions = mediaList
|
availableVersions = mediaList
|
||||||
.map((media) => PlexMappers.mediaVersionFromJson(media as Map<String, dynamic>))
|
.map((media) => PlexMappers.mediaVersionFromJson(media as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Ensure the requested index is valid
|
|
||||||
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
|
||||||
mediaIndex = 0;
|
mediaIndex = 0;
|
||||||
}
|
}
|
||||||
@@ -1281,15 +1257,11 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
final partKey = part['key'] as String?;
|
final partKey = part['key'] as String?;
|
||||||
|
|
||||||
if (partKey != null) {
|
if (partKey != null) {
|
||||||
// Get video URL
|
|
||||||
videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token);
|
videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token);
|
||||||
|
|
||||||
// Parse streams using shared parser
|
|
||||||
final streams = walkStreams(part['Stream'] as List<dynamic>?, _streamReader);
|
final streams = walkStreams(part['Stream'] as List<dynamic>?, _streamReader);
|
||||||
// Parse chapters using helper
|
|
||||||
final chapters = _parseChapters(metadataJson);
|
final chapters = _parseChapters(metadataJson);
|
||||||
|
|
||||||
// Create media info
|
|
||||||
mediaInfo = MediaSourceInfo(
|
mediaInfo = MediaSourceInfo(
|
||||||
videoUrl: videoUrl,
|
videoUrl: videoUrl,
|
||||||
audioTracks: streams.audioTracks,
|
audioTracks: streams.audioTracks,
|
||||||
@@ -1941,10 +1913,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Metadata Editing Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Update metadata fields for a media item
|
/// Update metadata fields for a media item
|
||||||
Future<bool> updateMetadata({
|
Future<bool> updateMetadata({
|
||||||
required int sectionId,
|
required int sectionId,
|
||||||
@@ -2099,10 +2067,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Collection Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get all collections for a library section
|
/// Get all collections for a library section
|
||||||
/// Returns collections as PlexMetadataDto objects with type="collection"
|
/// Returns collections as PlexMetadataDto objects with type="collection"
|
||||||
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
||||||
@@ -2255,10 +2219,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Play Queue Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
|
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
|
||||||
/// MediaItem-typed entries.
|
/// MediaItem-typed entries.
|
||||||
PlayQueueResponse _parsePlayQueueResponse(dynamic data) {
|
PlayQueueResponse _parsePlayQueueResponse(dynamic data) {
|
||||||
@@ -2496,10 +2456,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
return _getPlaylists(playlistType: playlistType);
|
return _getPlaylists(playlistType: playlistType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Library Management Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Scan/refresh a library section to detect new files
|
/// Scan/refresh a library section to detect new files
|
||||||
Future<void> scanLibrary(String sectionId) async {
|
Future<void> scanLibrary(String sectionId) async {
|
||||||
await _getWithFailover('/library/sections/$sectionId/refresh');
|
await _getWithFailover('/library/sections/$sectionId/refresh');
|
||||||
@@ -2521,10 +2477,6 @@ class PlexClient with MediaServerCacheMixin implements MediaServerClient {
|
|||||||
await _getWithFailover('/library/sections/$sectionId/analyze');
|
await _getWithFailover('/library/sections/$sectionId/analyze');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Live TV / DVR Methods
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// Get all DVR devices configured on this server
|
/// Get all DVR devices configured on this server
|
||||||
Future<List<LiveTvDvr>> getDvrs() async {
|
Future<List<LiveTvDvr>> getDvrs() async {
|
||||||
return _wrapListApiCall<LiveTvDvr>(() => _http.get('/livetv/dvrs'), (response) {
|
return _wrapListApiCall<LiveTvDvr>(() => _http.get('/livetv/dvrs'), (response) {
|
||||||
|
|||||||
@@ -269,8 +269,6 @@ class SettingsExportService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- File operations -----------------------------------------------------
|
|
||||||
|
|
||||||
static Future<String> _defaultFileName() async {
|
static Future<String> _defaultFileName() async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final y = now.year.toString().padLeft(4, '0');
|
final y = now.year.toString().padLeft(4, '0');
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import '../utils/future_extensions.dart';
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/language_codes.dart';
|
import '../utils/language_codes.dart';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Track Matching Utilities
|
|
||||||
// ============================================================================
|
|
||||||
// These functions match MPV tracks to Plex tracks by properties (language,
|
// These functions match MPV tracks to Plex tracks by properties (language,
|
||||||
// codec, title, etc.) instead of list index, since the two may be ordered
|
// codec, title, etc.) instead of list index, since the two may be ordered
|
||||||
// differently.
|
// differently.
|
||||||
|
|||||||
@@ -82,10 +82,6 @@ class MediaServerHttpClient {
|
|||||||
Duration connectTimeout;
|
Duration connectTimeout;
|
||||||
Duration receiveTimeout;
|
Duration receiveTimeout;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public request methods
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<MediaServerResponse> get(
|
Future<MediaServerResponse> get(
|
||||||
String path, {
|
String path, {
|
||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
@@ -190,10 +186,6 @@ class MediaServerHttpClient {
|
|||||||
|
|
||||||
void close() => _client.close();
|
void close() => _client.close();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Core send implementation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Future<MediaServerResponse> _send(
|
Future<MediaServerResponse> _send(
|
||||||
String method,
|
String method,
|
||||||
String path, {
|
String path, {
|
||||||
@@ -260,10 +252,6 @@ class MediaServerHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// URI building
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Build a full URI from [baseUrl] + [path] + [queryParameters].
|
/// Build a full URI from [baseUrl] + [path] + [queryParameters].
|
||||||
/// Use this from callers that need to construct URLs with the client's
|
/// Use this from callers that need to construct URLs with the client's
|
||||||
/// current (possibly failover-switched) base, rather than reading
|
/// current (possibly failover-switched) base, rather than reading
|
||||||
@@ -307,10 +295,6 @@ class MediaServerHttpClient {
|
|||||||
|
|
||||||
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
|
static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Body serialization
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Set the request body, choosing encoding based on the body type.
|
/// Set the request body, choosing encoding based on the body type.
|
||||||
void _setBody(http.Request request, Object? body) {
|
void _setBody(http.Request request, Object? body) {
|
||||||
if (body == null) return;
|
if (body == null) return;
|
||||||
@@ -338,10 +322,6 @@ class MediaServerHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response decoding
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Decode the response body: lenient UTF-8, then JSON parse if applicable.
|
/// Decode the response body: lenient UTF-8, then JSON parse if applicable.
|
||||||
/// Large payloads are decoded in a background isolate.
|
/// Large payloads are decoded in a background isolate.
|
||||||
Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async {
|
Future<dynamic> _decodeBody(List<int> bytes, Map<String, String> headers) async {
|
||||||
@@ -375,10 +355,6 @@ class MediaServerHttpClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Logging
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
void _logResponse(String method, Uri uri, int statusCode, int ms) {
|
void _logResponse(String method, Uri uri, int statusCode, int ms) {
|
||||||
appLogger.d('$method ${LogRedactionManager.redact(uri.toString())} → $statusCode (${ms}ms)');
|
appLogger.d('$method ${LogRedactionManager.redact(uri.toString())} → $statusCode (${ms}ms)');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user