chore: clean up redundant comments

This commit is contained in:
edde746
2026-05-01 05:02:26 +02:00
parent 117d1b8882
commit 9bd5732f2b
25 changed files with 8 additions and 544 deletions
-12
View File
@@ -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();
-18
View File
@@ -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;
-14
View File
@@ -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) {
-4
View File
@@ -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;
-44
View File
@@ -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:
-20
View File
@@ -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;
-32
View File
@@ -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;
-4
View File
@@ -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);
-8
View File
@@ -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++) ...[
-28
View File
@@ -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',
-12
View File
@@ -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),
-14
View File
@@ -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),
+7 -72
View File
@@ -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;
+1 -60
View File
@@ -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);
-48
View File
@@ -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.
-24
View File
@@ -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)');
} }