diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 0dbb1e49..54771818 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -23,6 +23,7 @@ class PlexMetadata with MultiServerFields { final int? duration; final int? addedAt; final int? updatedAt; + final int? lastViewedAt; // Timestamp when item was last viewed final String? grandparentTitle; // Show title for episodes final String? grandparentThumb; // Show poster for episodes final String? grandparentArt; // Show art for episodes @@ -78,6 +79,7 @@ class PlexMetadata with MultiServerFields { this.duration, this.addedAt, this.updatedAt, + this.lastViewedAt, this.grandparentTitle, this.grandparentThumb, this.grandparentArt, @@ -121,6 +123,7 @@ class PlexMetadata with MultiServerFields { int? duration, int? addedAt, int? updatedAt, + int? lastViewedAt, String? grandparentTitle, String? grandparentThumb, String? grandparentArt, @@ -162,6 +165,7 @@ class PlexMetadata with MultiServerFields { duration: duration ?? this.duration, addedAt: addedAt ?? this.addedAt, updatedAt: updatedAt ?? this.updatedAt, + lastViewedAt: lastViewedAt ?? this.lastViewedAt, grandparentTitle: grandparentTitle ?? this.grandparentTitle, grandparentThumb: grandparentThumb ?? this.grandparentThumb, grandparentArt: grandparentArt ?? this.grandparentArt, diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index d5017ef0..050b3ee2 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -23,6 +23,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( duration: (json['duration'] as num?)?.toInt(), addedAt: (json['addedAt'] as num?)?.toInt(), updatedAt: (json['updatedAt'] as num?)?.toInt(), + lastViewedAt: (json['lastViewedAt'] as num?)?.toInt(), grandparentTitle: json['grandparentTitle'] as String?, grandparentThumb: json['grandparentThumb'] as String?, grandparentArt: json['grandparentArt'] as String?, @@ -66,6 +67,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => 'duration': instance.duration, 'addedAt': instance.addedAt, 'updatedAt': instance.updatedAt, + 'lastViewedAt': instance.lastViewedAt, 'grandparentTitle': instance.grandparentTitle, 'grandparentThumb': instance.grandparentThumb, 'grandparentArt': instance.grandparentArt, diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 2b7b71ed..cbeb3bb6 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -552,8 +552,9 @@ class _LibrariesScreenState extends State // Compute visible libraries after hiding final visibleLibraries = _allLibraries .where( - (lib) => !hiddenLibrariesProvider.hiddenLibraryKeys - .contains(lib.globalKey), + (lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains( + lib.globalKey, + ), ) .toList(); @@ -1335,10 +1336,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { scrollController, hiddenLibraryKeys, ) - : _buildFlatLibraryList( - scrollController, - hiddenLibraryKeys, - ), + : _buildFlatLibraryList(scrollController, hiddenLibraryKeys), ), ], ); @@ -1346,7 +1344,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } - /// Build flat library list (single server) Widget _buildFlatLibraryList( ScrollController scrollController, @@ -1411,11 +1408,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { index: serverIndex, child: Icon( Icons.drag_indicator, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.5), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), ), ), title: Text( @@ -1507,11 +1502,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { padding: const EdgeInsets.only(right: 12), child: Icon( Icons.drag_indicator, - color: Theme.of(context) - .textTheme - .bodyMedium - ?.color - ?.withValues(alpha: 0.5), + color: Theme.of( + context, + ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), ), ), ), @@ -1526,22 +1519,18 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { if (showServerBadge && _hasMultipleServers && library.serverName != null) - ServerBadge( - serverName: library.serverName, - showFullName: true, - ), + ServerBadge(serverName: library.serverName, showFullName: true), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: Icon( - isHidden ? Icons.visibility_off : Icons.visibility, - ), + icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), onPressed: () => widget.onToggleVisibility(library), - tooltip: - isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, + tooltip: isHidden + ? t.libraries.showLibrary + : t.libraries.hideLibrary, ), IconButton( icon: const Icon(Icons.more_vert), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index fe250656..ddd2db81 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -11,14 +11,15 @@ import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; -import '../models/plex_user_profile.dart'; import '../providers/playback_state_provider.dart'; import '../services/episode_navigation_service.dart'; import '../services/media_controls_manager.dart'; +import '../services/playback_initialization_service.dart'; import '../services/playback_progress_tracker.dart'; import '../services/settings_service.dart'; +import '../services/track_selection_service.dart'; +import '../services/video_filter_manager.dart'; import '../utils/app_logger.dart'; -import '../utils/language_codes.dart'; import '../utils/orientation_helper.dart'; import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; @@ -73,6 +74,8 @@ class VideoPlayerScreenState extends State // Services MediaControlsManager? _mediaControlsManager; PlaybackProgressTracker? _progressTracker; + VideoFilterManager? _videoFilterManager; + TrackSelectionService? _trackSelectionService; final EpisodeNavigationService _episodeNavigation = EpisodeNavigationService(); @@ -81,18 +84,10 @@ class VideoPlayerScreenState extends State return context.getClientForServer(widget.metadata.serverId); } - // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) - int _boxFitMode = 0; - bool _isPinching = false; // Track if a pinch gesture is occurring final ValueNotifier _isBuffering = ValueNotifier( false, ); // Track if video is currently buffering - // Video cropping state for fill screen mode - Size? _playerSize; - Size? _videoSize; - Timer? _resizeDebounceTimer; - @override void initState() { super.initState(); @@ -154,7 +149,7 @@ class VideoPlayerScreenState extends State // Update video filter when dependencies change (orientation, screen size, etc.) WidgetsBinding.instance.addPostFrameCallback((_) { - _debouncedUpdateVideoFilter(); + _videoFilterManager?.debouncedUpdateVideoFilter(); }); } @@ -294,7 +289,7 @@ class VideoPlayerScreenState extends State } // Get the video URL and start playback - _startPlayback(); + await _startPlayback(); // Set fullscreen mode and orientation based on rotation lock setting if (mounted) { @@ -554,132 +549,54 @@ class VideoPlayerScreenState extends State throw Exception('No client available'); } - // Get consolidated playback data (URL, media info, and versions) in a single API call - final playbackData = await client.getVideoPlaybackData( - widget.metadata.ratingKey, - mediaIndex: widget.selectedMediaIndex, + // Initialize playback service + final playbackService = PlaybackInitializationService( + player: player!, + client: client, + context: context, ); - if (playbackData.hasValidVideoUrl) { - final videoUrl = playbackData.videoUrl!; - final mediaInfo = playbackData.mediaInfo; + // Start playback and get available versions + final result = await playbackService.startPlayback( + metadata: widget.metadata, + selectedMediaIndex: widget.selectedMediaIndex, + ); - // Update available versions from the playback data - if (mounted) { - setState(() { - _availableVersions = playbackData.availableVersions; - }); + // Update available versions from the playback data + if (mounted) { + setState(() { + _availableVersions = result.availableVersions.cast(); + }); + + // Initialize video filter manager with player and available versions + if (player != null && _availableVersions.isNotEmpty) { + _videoFilterManager = VideoFilterManager( + player: player!, + availableVersions: _availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + ); // Update video filter once dimensions are available - _updateVideoFilter(); + _videoFilterManager!.updateVideoFilter(); } + } - // Build list of external subtitle tracks for media_kit - final externalSubtitles = []; - if (mediaInfo != null) { - final externalTracks = mediaInfo.subtitleTracks - .where((track) => track.isExternal) - .toList(); + // Initialize track selection service and apply tracks + _trackSelectionService = TrackSelectionService( + player: player!, + profileSettings: context.profileSettings, + metadata: widget.metadata, + ); - if (externalTracks.isNotEmpty) { - appLogger.d( - 'Found ${externalTracks.length} external subtitle track(s)', - ); - } - - for (final plexTrack in externalTracks) { - try { - // Skip if no auth token is available - final token = client.config.token; - if (token == null) { - appLogger.w('No auth token available for external subtitles'); - continue; - } - - final url = plexTrack.getSubtitleUrl( - client.config.baseUrl, - token, - ); - - // Skip if URL couldn't be constructed - if (url == null) continue; - - externalSubtitles.add( - SubtitleTrack.uri( - url, - title: - plexTrack.displayTitle ?? - plexTrack.language ?? - 'Track ${plexTrack.id}', - language: plexTrack.languageCode, - ), - ); - } catch (e) { - // Silent fallback - log error but continue with other subtitles - appLogger.w( - 'Failed to add external subtitle track ${plexTrack.id}', - error: e, - ); - } - } - } - - // Open video (without external subtitles in Media constructor) - await player!.open(Media(videoUrl), play: false); - - // Wait for media to be ready (duration > 0) - int attempts = 0; - while (player!.state.duration.inMilliseconds == 0 && attempts < 100) { - await Future.delayed(const Duration(milliseconds: 100)); - attempts++; - } - - // Add external subtitle tracks without auto-selecting them - if (externalSubtitles.isNotEmpty) { - appLogger.d( - 'Adding ${externalSubtitles.length} external subtitle(s) to player', - ); - - final nativePlayer = player!.platform as dynamic; - - for (final subtitleTrack in externalSubtitles) { - try { - // Use mpv's sub-add with 'auto' flag to avoid auto-selection - await nativePlayer.command([ - 'sub-add', - subtitleTrack.id, - 'auto', - subtitleTrack.title ?? 'external', - subtitleTrack.language ?? 'auto', - ]); - } catch (e) { - appLogger.w( - 'Failed to add external subtitle: ${subtitleTrack.title}', - error: e, - ); - } - } - } - - // Set up playback position if resuming - if (widget.metadata.viewOffset != null && - widget.metadata.viewOffset! > 0) { - final resumePosition = Duration( - milliseconds: widget.metadata.viewOffset!, - ); - await player!.seek(resumePosition); - } - - // Start playback after seeking - await player!.play(); - - // Wait for tracks to be loaded, then apply preferred tracks - _waitForTracksAndApply(); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.messages.fileInfoNotAvailable)), - ); - } + await _trackSelectionService!.selectAndApplyTracks( + preferredAudioTrack: widget.preferredAudioTrack, + preferredSubtitleTrack: widget.preferredSubtitleTrack, + preferredPlaybackRate: widget.preferredPlaybackRate, + ); + } on PlaybackException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); } } catch (e) { if (mounted) { @@ -693,209 +610,14 @@ class VideoPlayerScreenState extends State /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void _cycleBoxFitMode() { setState(() { - _boxFitMode = (_boxFitMode + 1) % 3; + _videoFilterManager?.cycleBoxFitMode(); }); - _updateVideoFilter(); } /// Toggle between contain and cover modes only (for pinch gesture) void _toggleContainCover() { setState(() { - _boxFitMode = _boxFitMode == 0 ? 1 : 0; - }); - _updateVideoFilter(); - } - - /// Get current BoxFit based on mode - BoxFit get _getCurrentBoxFit { - switch (_boxFitMode) { - case 0: - return BoxFit.contain; - case 1: - return BoxFit.cover; - case 2: - return BoxFit.fill; - default: - return BoxFit.contain; - } - } - - /// Calculates crop parameters for "fill screen" mode (BoxFit.cover) to eliminate letterboxing. - /// - /// This method is only active when [_boxFitMode] == 1 (cover mode). It determines how to - /// crop the video to completely fill the player area while maintaining aspect ratio. - /// - /// **How it works:** - /// 1. Compares video aspect ratio vs player aspect ratio - /// 2. Crops the dimension that would create letterboxing: - /// - Wide video (16:9) on tall player (4:3): crops left/right sides - /// - Tall video (4:3) on wide player (16:9): crops top/bottom - /// 3. Centers the crop within the video - /// 4. Calculates subtitle margin adjustments to keep subtitles visible - /// - /// **Subtitle positioning:** - /// MPV uses a 720p reference coordinate system for subtitle positioning. - /// When cropping zooms the video, subtitles need larger margins to avoid - /// being cropped or appearing too close to edges. - /// - /// Returns `null` if: - /// - Not in cover mode (_boxFitMode != 1) - /// - Player or video size is unknown - /// - Aspect ratios are too similar (< 0.01 difference) - no crop needed - /// - /// Returns a map containing: - /// - `width`, `height`: Dimensions of the cropped area in video pixels - /// - `x`, `y`: Crop offset from video's top-left corner in pixels - /// - `subMarginX`, `subMarginY`: Subtitle margins in MPV coordinate space (720p reference) - /// - `subScale`: Subtitle scaling factor (currently always 1.0) - Map? _calculateCropParameters() { - // Only calculate for cover mode with known dimensions - if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) { - return null; - } - - final playerAspect = _playerSize!.width / _playerSize!.height; - final videoAspect = _videoSize!.width / _videoSize!.height; - - // No cropping needed if aspect ratios are very similar - if ((playerAspect - videoAspect).abs() < 0.01) return null; - - late final int cropW, cropH, cropX, cropY; - - if (videoAspect > playerAspect) { - // Video is wider than player - crop left/right sides - // Example: 16:9 video in 4:3 player - final scale = _playerSize!.height / _videoSize!.height; - cropH = _videoSize!.height.toInt(); - cropW = (_playerSize!.width / scale).toInt(); - cropX = ((_videoSize!.width - cropW) ~/ 2); // Center horizontally - cropY = 0; - } else { - // Video is taller than player - crop top/bottom - // Example: 4:3 video in 16:9 player (most common case) - final scale = _playerSize!.width / _videoSize!.width; - cropW = _videoSize!.width.toInt(); - cropH = (_playerSize!.height / scale).toInt(); - cropX = 0; - cropY = ((_videoSize!.height - cropH) ~/ 2); // Center vertically - } - - // Subtitle positioning constants - /// MPV's subtitle coordinate system height (720p reference) - const double kSubCoord = 720.0; - - /// Base horizontal subtitle margin to prevent edge clipping - const double baseX = 20.0; - - /// Base vertical subtitle margin, tuned to position subtitles - /// comfortably above the bottom while avoiding overscan areas - const double baseY = 45.0; - - // Calculate additional margin needed due to cropping - // When we crop, the visible area is "zoomed in", so subtitles need - // proportionally larger margins to maintain the same visual distance from edges - double extraX = cropX > 0 - ? (cropX / _videoSize!.width) * kSubCoord * videoAspect - : 0.0; - double extraY = cropY > 0 ? (cropY / _videoSize!.height) * kSubCoord : 0.0; - - // Apply additional margin (never reduce below base) - int marginX = (baseX + extraX).round(); - int marginY = (baseY + extraY).round(); - - return { - 'width': cropW, - 'height': cropH, - 'x': cropX, - 'y': cropY, - 'subMarginX': marginX, - 'subMarginY': marginY, - 'subScale': 1.0, - }; - } - - /// Get video dimensions from the currently selected media version - Size? _getCurrentVideoSize() { - if (_availableVersions.isEmpty || - widget.selectedMediaIndex >= _availableVersions.length) { - return null; - } - - final currentVersion = _availableVersions[widget.selectedMediaIndex]; - if (currentVersion.width != null && currentVersion.height != null) { - return Size( - currentVersion.width!.toDouble(), - currentVersion.height!.toDouble(), - ); - } - - return null; - } - - /// Update the video filter based on current crop mode - void _updateVideoFilter() async { - if (player == null) return; - - try { - final nativePlayer = player!.platform as dynamic; - - if (_boxFitMode == 1) { - // Fill screen mode - apply crop filter - _videoSize = _getCurrentVideoSize(); - final cropParams = _calculateCropParameters(); - - if (cropParams != null) { - final cropFilter = - 'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}'; - appLogger.d( - 'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)', - ); - - // Apply crop filter - await nativePlayer.setProperty('vf', cropFilter); - - // Apply subtitle margins and scaling to compensate for crop zoom - final subMarginX = cropParams['subMarginX']!; - final subMarginY = cropParams['subMarginY']!; - final subScale = cropParams['subScale']!; - - appLogger.d( - 'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale', - ); - - await nativePlayer.setProperty('sub-margin-x', subMarginX.toString()); - await nativePlayer.setProperty('sub-margin-y', subMarginY.toString()); - await nativePlayer.setProperty('sub-scale', subScale.toString()); - } else { - // Clear filter but apply base margins if no cropping needed - appLogger.d( - 'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)', - ); - await nativePlayer.setProperty('vf', ''); - await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin - await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin - await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale - } - } else { - // Other modes - clear video filter but apply base margins - appLogger.d( - 'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode', - ); - await nativePlayer.setProperty('vf', ''); - await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin - await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin - await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale - } - } catch (e) { - appLogger.w('Failed to update video filter', error: e); - } - } - - /// Debounced version of _updateVideoFilter for resize events - void _debouncedUpdateVideoFilter() { - _resizeDebounceTimer?.cancel(); - _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { - _updateVideoFilter(); + _videoFilterManager?.toggleContainCover(); }); } @@ -912,8 +634,8 @@ class VideoPlayerScreenState extends State _progressTracker?.stopTracking(); _progressTracker?.dispose(); - // Cancel debounce timer - _resizeDebounceTimer?.cancel(); + // Dispose video filter manager + _videoFilterManager?.dispose(); // Cancel stream subscriptions _playingSubscription?.cancel(); @@ -972,689 +694,6 @@ class VideoPlayerScreenState extends State super.dispose(); } - /// Generic track matching for audio and subtitle tracks - /// Returns the best matching track based on hierarchical criteria: - /// 1. Exact match (id + title + language) - /// 2. Partial match (title + language) - /// 3. Language-only match - T? _findBestTrackMatch( - List availableTracks, - T preferred, - String Function(T) getId, - String? Function(T) getTitle, - String? Function(T) getLanguage, - ) { - if (availableTracks.isEmpty) return null; - - // Filter out auto and no tracks - final validTracks = availableTracks - .where((t) => getId(t) != 'auto' && getId(t) != 'no') - .toList(); - if (validTracks.isEmpty) return null; - - final preferredId = getId(preferred); - final preferredTitle = getTitle(preferred); - final preferredLanguage = getLanguage(preferred); - - // Try to match: id, title, and language - for (var track in validTracks) { - if (getId(track) == preferredId && - getTitle(track) == preferredTitle && - getLanguage(track) == preferredLanguage) { - return track; - } - } - - // Try to match: title and language - for (var track in validTracks) { - if (getTitle(track) == preferredTitle && - getLanguage(track) == preferredLanguage) { - return track; - } - } - - // Try to match: language only - for (var track in validTracks) { - if (getLanguage(track) == preferredLanguage) { - return track; - } - } - - return null; - } - - AudioTrack? _findBestAudioMatch( - List availableTracks, - AudioTrack preferred, - ) { - return _findBestTrackMatch( - availableTracks, - preferred, - (t) => t.id, - (t) => t.title, - (t) => t.language, - ); - } - - AudioTrack? _findAudioTrackByProfile( - List availableTracks, - PlexUserProfile profile, - ) { - appLogger.d('Audio track selection using user profile'); - appLogger.d( - 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}, defaultAudioLanguages: ${profile.defaultAudioLanguages}', - ); - - if (availableTracks.isEmpty || !profile.autoSelectAudio) { - appLogger.d( - 'Cannot use profile: ${availableTracks.isEmpty ? "No tracks available" : "autoSelectAudio is false"}', - ); - return null; - } - - // Build list of preferred languages - final preferredLanguages = []; - if (profile.defaultAudioLanguage != null && - profile.defaultAudioLanguage!.isNotEmpty) { - preferredLanguages.add(profile.defaultAudioLanguage!); - } - if (profile.defaultAudioLanguages != null) { - preferredLanguages.addAll(profile.defaultAudioLanguages!); - } - - if (preferredLanguages.isEmpty) { - appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified'); - return null; - } - - appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); - - // Try to find track matching any preferred language - for (final preferredLanguage in preferredLanguages) { - // Get all possible language code variations (e.g., "en" → ["en", "eng"]) - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', - ); - - for (var track in availableTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.contains(trackLang)) { - appLogger.d( - 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; - } - } - } - - appLogger.d( - 'No audio track found matching profile languages or their variations', - ); - return null; - } - - SubtitleTrack? _findBestSubtitleMatch( - List availableTracks, - SubtitleTrack preferred, - ) { - // Handle special "no subtitles" case - if (preferred.id == 'no') { - return SubtitleTrack.no(); - } - - return _findBestTrackMatch( - availableTracks, - preferred, - (t) => t.id, - (t) => t.title, - (t) => t.language, - ); - } - - SubtitleTrack? _findSubtitleTrackByProfile( - List availableTracks, - PlexUserProfile profile, { - AudioTrack? selectedAudioTrack, - }) { - appLogger.d('Subtitle track selection using user profile'); - appLogger.d( - 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleLanguages: ${profile.defaultSubtitleLanguages}, defaultSubtitleForced: ${profile.defaultSubtitleForced}, defaultSubtitleAccessibility: ${profile.defaultSubtitleAccessibility}', - ); - - if (availableTracks.isEmpty) { - appLogger.d('Cannot use profile: No subtitle tracks available'); - return null; - } - - // Mode 0: Manually selected - return OFF - if (profile.autoSelectSubtitle == 0) { - appLogger.d( - 'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF', - ); - return SubtitleTrack.no(); - } - - // Mode 1: Shown with foreign audio - if (profile.autoSelectSubtitle == 1) { - appLogger.d( - 'Profile specifies foreign audio mode (autoSelectSubtitle=1)', - ); - - // Check if audio language matches user's preferred subtitle language - if (selectedAudioTrack != null && - profile.defaultSubtitleLanguage != null) { - final audioLang = selectedAudioTrack.language?.toLowerCase(); - final prefLang = profile.defaultSubtitleLanguage!.toLowerCase(); - final languageVariations = LanguageCodes.getVariations(prefLang); - - appLogger.d( - 'Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang', - ); - - // If audio matches preferred language, no subtitles needed - if (audioLang != null && languageVariations.contains(audioLang)) { - appLogger.d('Audio matches preferred language - Subtitles OFF'); - return SubtitleTrack.no(); - } - appLogger.d('Foreign audio detected - enabling subtitles'); - } - // Foreign audio detected or cannot determine, enable subtitles - } - - // Mode 2: Always enabled (or continuing from mode 1 with foreign audio) - appLogger.d('Selecting subtitle track based on preferences'); - - // Build list of preferred languages - final preferredLanguages = []; - if (profile.defaultSubtitleLanguage != null && - profile.defaultSubtitleLanguage!.isNotEmpty) { - preferredLanguages.add(profile.defaultSubtitleLanguage!); - } - if (profile.defaultSubtitleLanguages != null) { - preferredLanguages.addAll(profile.defaultSubtitleLanguages!); - } - - if (preferredLanguages.isEmpty) { - appLogger.d( - 'Cannot use profile: No defaultSubtitleLanguage(s) specified', - ); - return null; - } - - appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); - - // Apply filtering based on preferences - var candidateTracks = availableTracks; - - // Filter by SDH (defaultSubtitleAccessibility: 0-3) - candidateTracks = _filterSubtitlesBySDH( - candidateTracks, - profile.defaultSubtitleAccessibility, - ); - - // Filter by forced subtitle preference (defaultSubtitleForced: 0-3) - candidateTracks = _filterSubtitlesByForced( - candidateTracks, - profile.defaultSubtitleForced, - ); - - // If no candidates after filtering, relax filters - if (candidateTracks.isEmpty) { - appLogger.d('No tracks match strict filters, relaxing filters'); - candidateTracks = availableTracks; - } - - // Try to find track matching any preferred language - for (final preferredLanguage in preferredLanguages) { - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', - ); - - for (var track in candidateTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.contains(trackLang)) { - appLogger.d( - 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; - } - } - } - - appLogger.d( - 'No subtitle track found matching profile languages or their variations', - ); - return null; - } - - /// Filters subtitle tracks based on SDH (Subtitles for Deaf or Hard-of-Hearing) preference - /// - /// Values: - /// - 0: Prefer non-SDH subtitles - /// - 1: Prefer SDH subtitles - /// - 2: Only show SDH subtitles - /// - 3: Only show non-SDH subtitles - List _filterSubtitlesBySDH( - List tracks, - int preference, - ) { - if (preference == 0 || preference == 1) { - // Prefer but don't require - final preferSDH = preference == 1; - final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList(); - if (preferred.isNotEmpty) { - appLogger.d( - 'Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)', - ); - return preferred; - } - appLogger.d('No tracks match SDH preference, using all tracks'); - return tracks; - } else if (preference == 2) { - // Only SDH - final filtered = tracks.where(_isSDH).toList(); - appLogger.d('Filtering to SDH only (${filtered.length} tracks)'); - return filtered; - } else if (preference == 3) { - // Only non-SDH - final filtered = tracks.where((t) => !_isSDH(t)).toList(); - appLogger.d('Filtering to non-SDH only (${filtered.length} tracks)'); - return filtered; - } - return tracks; - } - - /// Filters subtitle tracks based on forced subtitle preference - /// - /// Values: - /// - 0: Prefer non-forced subtitles - /// - 1: Prefer forced subtitles - /// - 2: Only show forced subtitles - /// - 3: Only show non-forced subtitles - List _filterSubtitlesByForced( - List tracks, - int preference, - ) { - if (preference == 0 || preference == 1) { - // Prefer but don't require - final preferForced = preference == 1; - final preferred = tracks - .where((t) => _isForced(t) == preferForced) - .toList(); - if (preferred.isNotEmpty) { - appLogger.d( - 'Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)', - ); - return preferred; - } - appLogger.d('No tracks match forced preference, using all tracks'); - return tracks; - } else if (preference == 2) { - // Only forced - final filtered = tracks.where(_isForced).toList(); - appLogger.d('Filtering to forced only (${filtered.length} tracks)'); - return filtered; - } else if (preference == 3) { - // Only non-forced - final filtered = tracks.where((t) => !_isForced(t)).toList(); - appLogger.d('Filtering to non-forced only (${filtered.length} tracks)'); - return filtered; - } - return tracks; - } - - /// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing) - /// - /// Since media_kit may not expose this directly, we infer from the title - bool _isSDH(SubtitleTrack track) { - final title = track.title?.toLowerCase() ?? ''; - - // Look for common SDH indicators - return title.contains('sdh') || - title.contains('cc') || - title.contains('hearing impaired') || - title.contains('deaf'); - } - - /// Checks if a subtitle track is forced - bool _isForced(SubtitleTrack track) { - final title = track.title?.toLowerCase() ?? ''; - return title.contains('forced'); - } - - /// Checks if a track language matches a preferred language - /// - /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes - /// Also handles bibliographic variants and region codes (e.g., "en-US") - bool _languageMatches(String? trackLanguage, String? preferredLanguage) { - if (trackLanguage == null || preferredLanguage == null) { - return false; - } - - final track = trackLanguage.toLowerCase(); - final preferred = preferredLanguage.toLowerCase(); - - // Direct match - if (track == preferred) return true; - - // Extract base language codes (handle region codes like "en-US") - final trackBase = track.split('-').first; - final preferredBase = preferred.split('-').first; - - if (trackBase == preferredBase) return true; - - // Get all variations of the preferred language (e.g., "en" → ["en", "eng"]) - final variations = LanguageCodes.getVariations(preferredBase); - - // Check if track's base code matches any variation - return variations.contains(trackBase); - } - - /// Log available tracks for debugging - void _logAvailableTracks( - List audioTracks, - List subtitleTracks, - ) { - appLogger.d('Available audio tracks: ${audioTracks.length}'); - for (var track in audioTracks) { - appLogger.d( - ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', - ); - } - appLogger.d('Available subtitle tracks: ${subtitleTracks.length}'); - for (var track in subtitleTracks) { - appLogger.d( - ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', - ); - } - } - - /// Select the best audio track based on priority: - /// Priority 1: Preferred track from navigation - /// Priority 2: Per-media language preference - /// Priority 3: User profile preferences - /// Priority 4: Default or first track - AudioTrack? _selectAudioTrack( - List availableTracks, - PlexUserProfile? profileSettings, - ) { - if (availableTracks.isEmpty) return null; - - AudioTrack? trackToSelect; - - // Priority 1: Try to match preferred track from navigation - if (widget.preferredAudioTrack != null) { - appLogger.d('Priority 1: Checking preferred track from navigation'); - appLogger.d( - ' Preferred: ${widget.preferredAudioTrack!.title ?? "Track ${widget.preferredAudioTrack!.id}"} (${widget.preferredAudioTrack!.language ?? "unknown"})', - ); - trackToSelect = _findBestAudioMatch( - availableTracks, - widget.preferredAudioTrack!, - ); - if (trackToSelect != null) { - appLogger.d(' Matched preferred track'); - return trackToSelect; - } - appLogger.d(' No match found for preferred track'); - } else { - appLogger.d('Priority 1: No preferred track from navigation'); - } - - // Priority 2: If no preferred track matched, try per-media language preference - if (widget.metadata.audioLanguage != null) { - appLogger.d('Priority 2: Checking per-media audio language preference'); - appLogger.d( - ' Per-media audio language: ${widget.metadata.audioLanguage}', - ); - - final matchedTrack = availableTracks.firstWhere( - (track) => - _languageMatches(track.language, widget.metadata.audioLanguage), - orElse: () => availableTracks.first, - ); - - if (_languageMatches( - matchedTrack.language, - widget.metadata.audioLanguage, - )) { - appLogger.d(' Matched per-media audio language preference'); - return matchedTrack; - } - appLogger.d(' No match found for per-media audio language'); - } else { - appLogger.d('Priority 2: No per-media audio language preference'); - } - - // Priority 3: If no preferred track matched, try user profile preferences - if (profileSettings != null) { - appLogger.d('Priority 3: Checking user profile preferences'); - trackToSelect = _findAudioTrackByProfile( - availableTracks, - profileSettings, - ); - if (trackToSelect != null) { - return trackToSelect; - } - } else { - appLogger.d('Priority 3: No user profile available'); - } - - // Priority 4: If no match, use default or first track - appLogger.d('Priority 4: Using default or first available track'); - trackToSelect = availableTracks.firstWhere( - (t) => t.isDefault == true, - orElse: () => availableTracks.first, - ); - final isDefault = trackToSelect.isDefault == true; - appLogger.d( - ' Selected ${isDefault ? "default" : "first"} track: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', - ); - - return trackToSelect; - } - - /// Select the best subtitle track based on priority: - /// Priority 1: Preferred track from navigation - /// Priority 2: Per-media language preference - /// Priority 3: User profile preferences - /// Priority 4: Default track - /// Priority 5: Off - SubtitleTrack _selectSubtitleTrack( - List availableTracks, - PlexUserProfile? profileSettings, - AudioTrack? selectedAudioTrack, - ) { - SubtitleTrack? subtitleToSelect; - - // Priority 1: Try preferred track from navigation (always wins) - if (widget.preferredSubtitleTrack != null) { - appLogger.d('Priority 1: Checking preferred track from navigation'); - if (widget.preferredSubtitleTrack!.id == 'no') { - appLogger.d(' Preferred: OFF'); - return SubtitleTrack.no(); - } else if (availableTracks.isNotEmpty) { - appLogger.d( - ' Preferred: ${widget.preferredSubtitleTrack!.title ?? "Track ${widget.preferredSubtitleTrack!.id}"} (${widget.preferredSubtitleTrack!.language ?? "unknown"})', - ); - subtitleToSelect = _findBestSubtitleMatch( - availableTracks, - widget.preferredSubtitleTrack!, - ); - if (subtitleToSelect != null) { - appLogger.d(' Matched preferred track'); - return subtitleToSelect; - } - appLogger.d(' No match found for preferred track'); - } - } else { - appLogger.d('Priority 1: No preferred track from navigation'); - } - - // Priority 2: If no preferred match, try per-media language preference - if (widget.metadata.subtitleLanguage != null) { - appLogger.d( - 'Priority 2: Checking per-media subtitle language preference', - ); - appLogger.d( - ' Per-media subtitle language: ${widget.metadata.subtitleLanguage}', - ); - - // Check if subtitle should be disabled - if (widget.metadata.subtitleLanguage == 'none' || - widget.metadata.subtitleLanguage!.isEmpty) { - appLogger.d(' Per-media preference: Subtitles OFF'); - return SubtitleTrack.no(); - } else if (availableTracks.isNotEmpty) { - final matchedTrack = availableTracks.firstWhere( - (track) => _languageMatches( - track.language, - widget.metadata.subtitleLanguage, - ), - orElse: () => availableTracks.first, - ); - if (_languageMatches( - matchedTrack.language, - widget.metadata.subtitleLanguage, - )) { - appLogger.d(' Matched per-media subtitle language preference'); - return matchedTrack; - } - appLogger.d(' No match found for per-media subtitle language'); - } - } else { - appLogger.d('Priority 2: No per-media subtitle language preference'); - } - - // Priority 3: If no preferred match, apply user profile preferences - if (profileSettings != null && availableTracks.isNotEmpty) { - appLogger.d('Priority 3: Checking user profile preferences'); - subtitleToSelect = _findSubtitleTrackByProfile( - availableTracks, - profileSettings, - selectedAudioTrack: selectedAudioTrack, - ); - if (subtitleToSelect != null) { - return subtitleToSelect; - } - } else if (availableTracks.isNotEmpty) { - appLogger.d('Priority 3: No user profile available'); - } - - // Priority 4: If no profile match, check for default subtitle - if (availableTracks.isNotEmpty) { - appLogger.d('Priority 4: Checking for default subtitle track'); - final defaultTrack = availableTracks.firstWhere( - (t) => t.isDefault == true, - orElse: () => availableTracks.first, - ); - if (defaultTrack.isDefault == true) { - appLogger.d( - ' Found default track: ${defaultTrack.title ?? "Track ${defaultTrack.id}"} (${defaultTrack.language ?? "unknown"})', - ); - return defaultTrack; - } - appLogger.d(' No default subtitle track found'); - } - - // Priority 5: If still no subtitle selected, turn off - appLogger.d('Priority 5: No subtitle selected - Subtitles OFF'); - return SubtitleTrack.no(); - } - - void _waitForTracksAndApply() async { - if (!mounted) return; - - // Process tracks and apply selections - Future processTracks(Tracks tracks) async { - if (!mounted) return; - - appLogger.d('Starting track selection process'); - - // Get profile settings for track selection - final profileSettings = context.profileSettings; - - // Get real tracks (excluding auto and no) - final realAudioTracks = tracks.audio - .where((t) => t.id != 'auto' && t.id != 'no') - .toList(); - final realSubtitleTracks = tracks.subtitle - .where((t) => t.id != 'auto' && t.id != 'no') - .toList(); - - // Log available tracks - _logAvailableTracks(realAudioTracks, realSubtitleTracks); - - // Select and apply audio track - appLogger.d('Audio track selection'); - final selectedAudioTrack = _selectAudioTrack( - realAudioTracks, - profileSettings, - ); - if (selectedAudioTrack != null) { - appLogger.i( - 'Final audio selection: ${selectedAudioTrack.title ?? "Track ${selectedAudioTrack.id}"} (${selectedAudioTrack.language ?? "unknown"})', - ); - player!.setAudioTrack(selectedAudioTrack); - } else { - appLogger.d('No audio tracks available'); - } - - // Select and apply subtitle track - appLogger.d('Subtitle track selection'); - final selectedSubtitleTrack = _selectSubtitleTrack( - realSubtitleTracks, - profileSettings, - selectedAudioTrack, - ); - final finalSubtitle = selectedSubtitleTrack.id == 'no' - ? 'OFF' - : '${selectedSubtitleTrack.title ?? "Track ${selectedSubtitleTrack.id}"} (${selectedSubtitleTrack.language ?? "unknown"})'; - appLogger.i('Final subtitle selection: $finalSubtitle'); - player!.setSubtitleTrack(selectedSubtitleTrack); - - // Set playback rate if preferred rate was provided - if (widget.preferredPlaybackRate != null) { - appLogger.d( - 'Setting preferred playback rate: ${widget.preferredPlaybackRate}x', - ); - player!.setRate(widget.preferredPlaybackRate!); - } - - appLogger.d('Track selection complete'); - } - - // Check if tracks are already available in current state - final currentTracks = player!.state.tracks; - if (currentTracks.audio.isNotEmpty || currentTracks.subtitle.isNotEmpty) { - await processTracks(currentTracks); - return; - } - - // If not, listen to tracks stream for when they become available - bool applied = false; - _trackLoadingSubscription = player!.stream.tracks.listen((tracks) async { - // Check if tracks are loaded (have at least one track) and not yet applied - if (!applied && (tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty)) { - applied = true; - await processTracks(tracks); - // Cancel subscription after successful processing - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; - } - }); - - // Cancel subscription after timeout if still waiting - Future.delayed(const Duration(seconds: 5), () { - if (!applied) { - _trackLoadingSubscription?.cancel(); - _trackLoadingSubscription = null; - } - }); - } - void _onPlayingStateChanged(bool isPlaying) { // Send timeline update when playback state changes _progressTracker?.sendProgress(isPlaying ? 'playing' : 'paused'); @@ -1914,21 +953,24 @@ class VideoPlayerScreenState extends State onScaleStart: (details) { // Initialize pinch gesture tracking (mobile only) if (!isMobile) return; - _isPinching = false; + if (_videoFilterManager != null) { + _videoFilterManager!.isPinching = false; + } }, onScaleUpdate: (details) { // Track if this is a pinch gesture (2+ fingers) on mobile if (!isMobile) return; - if (details.pointerCount >= 2) { - _isPinching = true; + if (details.pointerCount >= 2 && _videoFilterManager != null) { + _videoFilterManager!.isPinching = true; } }, onScaleEnd: (details) { // Only toggle if we detected a pinch gesture on mobile if (!isMobile) return; - if (_isPinching) { + if (_videoFilterManager != null && + _videoFilterManager!.isPinching) { _toggleContainCover(); - _isPinching = false; + _videoFilterManager!.isPinching = false; } }, child: Stack( @@ -1943,24 +985,18 @@ class VideoPlayerScreenState extends State constraints.maxHeight, ); - // Check if size actually changed to avoid unnecessary updates - if (_playerSize == null || - (_playerSize!.width - newSize.width).abs() > 0.1 || - (_playerSize!.height - newSize.height).abs() > 0.1) { + // Update player size in video filter manager + if (_videoFilterManager != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { - setState(() { - _playerSize = newSize; - }); - // Use debounced update for resize events - _debouncedUpdateVideoFilter(); + _videoFilterManager!.updatePlayerSize(newSize); } }); } return Video( controller: controller!, - fit: _getCurrentBoxFit, + fit: _videoFilterManager?.currentBoxFit ?? BoxFit.contain, controls: (state) => plexVideoControlsBuilder( player!, widget.metadata, @@ -1970,7 +1006,7 @@ class VideoPlayerScreenState extends State : null, availableVersions: _availableVersions, selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: _boxFitMode, + boxFitMode: _videoFilterManager?.boxFitMode ?? 0, onCycleBoxFitMode: _cycleBoxFitMode, onAudioTrackChanged: _onAudioTrackChanged, onSubtitleTrackChanged: _onSubtitleTrackChanged, diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 2d4080e1..d74aa023 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -56,11 +56,11 @@ class DataAggregationService { }, ); - // Sort by most recent (lastViewedAt is stored in viewOffset metadata) - // For on deck items, we use updatedAt or addedAt as proxy for recency + // Sort by most recently viewed + // Use lastViewedAt (when item was last viewed), falling back to updatedAt/addedAt if not available allOnDeck.sort((a, b) { - final aTime = a.updatedAt ?? a.addedAt ?? 0; - final bTime = b.updatedAt ?? b.addedAt ?? 0; + final aTime = a.lastViewedAt ?? a.updatedAt ?? a.addedAt ?? 0; + final bTime = b.lastViewedAt ?? b.updatedAt ?? b.addedAt ?? 0; return bTime.compareTo(aTime); // Descending (most recent first) }); @@ -298,7 +298,9 @@ class DataAggregationService { final allResults = []; // Execute operation on all servers in parallel - final Iterable>> futures = clients.entries.map((entry) async { + final Iterable>> futures = clients.entries.map(( + entry, + ) async { final serverId = entry.key; final client = entry.value; final server = _serverManager.getServer(serverId); diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart new file mode 100644 index 00000000..3aa6f701 --- /dev/null +++ b/lib/services/playback_initialization_service.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../client/plex_client.dart'; +import '../models/plex_media_info.dart'; +import '../models/plex_metadata.dart'; +import '../utils/app_logger.dart'; +import '../i18n/strings.g.dart'; + +/// Service responsible for initializing video playback +/// +/// Handles the complex process of: +/// 1. Fetching video playback data from the Plex server +/// 2. Building external subtitle tracks +/// 3. Opening media in the player +/// 4. Adding external subtitles to the player +/// 5. Seeking to resume position +/// 6. Starting playback +class PlaybackInitializationService { + final Player player; + final PlexClient client; + final BuildContext context; + + PlaybackInitializationService({ + required this.player, + required this.client, + required this.context, + }); + + /// Start playback for the given metadata + /// + /// Returns a PlaybackInitializationResult with available versions and other data + Future startPlayback({ + required PlexMetadata metadata, + required int selectedMediaIndex, + }) async { + try { + // Get consolidated playback data (URL, media info, and versions) in a single API call + final playbackData = await client.getVideoPlaybackData( + metadata.ratingKey, + mediaIndex: selectedMediaIndex, + ); + + if (!playbackData.hasValidVideoUrl) { + throw PlaybackException(t.messages.fileInfoNotAvailable); + } + + final videoUrl = playbackData.videoUrl!; + final mediaInfo = playbackData.mediaInfo; + + // Build list of external subtitle tracks for media_kit + final externalSubtitles = _buildExternalSubtitles(mediaInfo); + + // Open video (without external subtitles in Media constructor) + await player.open(Media(videoUrl), play: false); + + // Wait for media to be ready (duration > 0) + await _waitForMediaReady(); + + // Add external subtitle tracks without auto-selecting them + if (externalSubtitles.isNotEmpty) { + await _addExternalSubtitles(externalSubtitles); + } + + // Set up playback position if resuming + if (metadata.viewOffset != null && metadata.viewOffset! > 0) { + final resumePosition = Duration(milliseconds: metadata.viewOffset!); + await player.seek(resumePosition); + } + + // Start playback after seeking + await player.play(); + + // Return result with available versions for UI updates + return PlaybackInitializationResult( + availableVersions: playbackData.availableVersions, + ); + } catch (e) { + if (e is PlaybackException) { + rethrow; + } + throw PlaybackException(t.messages.errorLoading(error: e.toString())); + } + } + + /// Build list of external subtitle tracks from media info + List _buildExternalSubtitles(PlexMediaInfo? mediaInfo) { + final externalSubtitles = []; + + if (mediaInfo == null) { + return externalSubtitles; + } + + final externalTracks = mediaInfo.subtitleTracks + .where((PlexSubtitleTrack track) => track.isExternal) + .toList(); + + if (externalTracks.isNotEmpty) { + appLogger.d('Found ${externalTracks.length} external subtitle track(s)'); + } + + for (final plexTrack in externalTracks) { + try { + // Skip if no auth token is available + final token = client.config.token; + if (token == null) { + appLogger.w('No auth token available for external subtitles'); + continue; + } + + final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token); + + // Skip if URL couldn't be constructed + if (url == null) continue; + + externalSubtitles.add( + SubtitleTrack.uri( + url, + title: + plexTrack.displayTitle ?? + plexTrack.language ?? + 'Track ${plexTrack.id}', + language: plexTrack.languageCode, + ), + ); + } catch (e) { + // Silent fallback - log error but continue with other subtitles + appLogger.w( + 'Failed to add external subtitle track ${plexTrack.id}', + error: e, + ); + } + } + + return externalSubtitles; + } + + /// Wait for media to be ready (duration > 0) + Future _waitForMediaReady() async { + int attempts = 0; + while (player.state.duration.inMilliseconds == 0 && attempts < 100) { + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + } + + /// Add external subtitle tracks to the player without auto-selecting them + Future _addExternalSubtitles( + List externalSubtitles, + ) async { + appLogger.d( + 'Adding ${externalSubtitles.length} external subtitle(s) to player', + ); + + final nativePlayer = player.platform as dynamic; + + for (final subtitleTrack in externalSubtitles) { + try { + // Use mpv's sub-add with 'auto' flag to avoid auto-selection + await nativePlayer.command([ + 'sub-add', + subtitleTrack.id, + 'auto', + subtitleTrack.title ?? 'external', + subtitleTrack.language ?? 'auto', + ]); + } catch (e) { + appLogger.w( + 'Failed to add external subtitle: ${subtitleTrack.title}', + error: e, + ); + } + } + } +} + +/// Result of playback initialization +class PlaybackInitializationResult { + final List availableVersions; + + PlaybackInitializationResult({required this.availableVersions}); +} + +/// Exception thrown when playback initialization fails +class PlaybackException implements Exception { + final String message; + + PlaybackException(this.message); + + @override + String toString() => message; +} diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 11c7da2b..c0fd28dd 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -1,228 +1,313 @@ -import '../models/plex_media_info.dart'; +import 'package:media_kit/media_kit.dart'; + import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; +import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; -/// Service for selecting audio and subtitle tracks based on user preferences +/// Service for selecting and applying audio and subtitle tracks based on +/// preferences, user profiles, and per-media settings. class TrackSelectionService { - /// Selects the best audio track based on user preferences - /// - /// Priority order: - /// 1. Per-media preferred audio language (from metadata.audioLanguage) - /// 2. Profile-wide language preferences (if auto-select is enabled) - /// 3. Plex's selected track (if auto-select is disabled) - /// 4. First track - /// - /// Returns the selected audio track, or null if no suitable track is found - static PlexAudioTrack? selectAudioTrack( - List tracks, - PlexUserProfile profile, { - PlexMetadata? metadata, - }) { - if (tracks.isEmpty) return null; + final Player player; + final PlexUserProfile? profileSettings; + final PlexMetadata metadata; - // Priority 1: Check for per-media audio language preference - if (metadata?.audioLanguage != null) { - final perMediaTrack = tracks.firstWhere( - (track) => - _matchesLanguage(track.languageCode, metadata!.audioLanguage), - orElse: () => tracks.first, - ); - // Only use it if we actually found a matching track - if (_matchesLanguage( - perMediaTrack.languageCode, - metadata!.audioLanguage, - )) { - return perMediaTrack; + TrackSelectionService({ + required this.player, + this.profileSettings, + required this.metadata, + }); + + /// Generic track matching for audio and subtitle tracks + /// Returns the best matching track based on hierarchical criteria: + /// 1. Exact match (id + title + language) + /// 2. Partial match (title + language) + /// 3. Language-only match + T? findBestTrackMatch( + List availableTracks, + T preferred, + String Function(T) getId, + String? Function(T) getTitle, + String? Function(T) getLanguage, + ) { + if (availableTracks.isEmpty) return null; + + // Filter out auto and no tracks + final validTracks = availableTracks + .where((t) => getId(t) != 'auto' && getId(t) != 'no') + .toList(); + if (validTracks.isEmpty) return null; + + final preferredId = getId(preferred); + final preferredTitle = getTitle(preferred); + final preferredLanguage = getLanguage(preferred); + + // Try to match: id, title, and language + for (var track in validTracks) { + if (getId(track) == preferredId && + getTitle(track) == preferredTitle && + getLanguage(track) == preferredLanguage) { + return track; } } - // Priority 2: If auto-select is disabled, use Plex's selected track - if (!profile.autoSelectAudio) { - return tracks.firstWhere( - (track) => track.selected, - orElse: () => tracks.first, - ); + // Try to match: title and language + for (var track in validTracks) { + if (getTitle(track) == preferredTitle && + getLanguage(track) == preferredLanguage) { + return track; + } } - // Priority 3: Use profile-wide language preferences + // Try to match: language only + for (var track in validTracks) { + if (getLanguage(track) == preferredLanguage) { + return track; + } + } + + return null; + } + + AudioTrack? findBestAudioMatch( + List availableTracks, + AudioTrack preferred, + ) { + return findBestTrackMatch( + availableTracks, + preferred, + (t) => t.id, + (t) => t.title, + (t) => t.language, + ); + } + + AudioTrack? findAudioTrackByProfile( + List availableTracks, + PlexUserProfile profile, + ) { + appLogger.d('Audio track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectAudio: ${profile.autoSelectAudio}, defaultAudioLanguage: ${profile.defaultAudioLanguage}, defaultAudioLanguages: ${profile.defaultAudioLanguages}', + ); + + if (availableTracks.isEmpty || !profile.autoSelectAudio) { + appLogger.d( + 'Cannot use profile: ${availableTracks.isEmpty ? "No tracks available" : "autoSelectAudio is false"}', + ); + return null; + } + + // Build list of preferred languages final preferredLanguages = []; - if (profile.defaultAudioLanguage != null) { + if (profile.defaultAudioLanguage != null && + profile.defaultAudioLanguage!.isNotEmpty) { preferredLanguages.add(profile.defaultAudioLanguage!); } if (profile.defaultAudioLanguages != null) { preferredLanguages.addAll(profile.defaultAudioLanguages!); } - // If no preferred languages, return first track if (preferredLanguages.isEmpty) { - return tracks.first; + appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified'); + return null; } - // Try to find a track matching preferred languages - for (final language in preferredLanguages) { - final matchingTrack = tracks.firstWhere( - (track) => _matchesLanguage(track.languageCode, language), - orElse: () => tracks.first, + appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); + + // Try to find track matching any preferred language + for (final preferredLanguage in preferredLanguages) { + // Get all possible language code variations (e.g., "en" → ["en", "eng"]) + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', ); - if (matchingTrack != tracks.first || - _matchesLanguage(matchingTrack.languageCode, language)) { - return matchingTrack; + + for (var track in availableTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } } } - // Fallback to first track - return tracks.first; + appLogger.d( + 'No audio track found matching profile languages or their variations', + ); + return null; } - /// Selects the best subtitle track based on user preferences - /// - /// Priority order: - /// 1. Per-media preferred subtitle language (from metadata.subtitleLanguage) - /// 2. Profile-wide subtitle preferences (based on auto-select mode) - /// 3. Disabled (null) - /// - /// Returns the selected subtitle track, or null if subtitles should be disabled - static PlexSubtitleTrack? selectSubtitleTrack( - List tracks, - PlexUserProfile profile, - PlexAudioTrack? selectedAudioTrack, { - PlexMetadata? metadata, - }) { - if (tracks.isEmpty) return null; - - // Priority 1: Check for per-media subtitle language preference - if (metadata?.subtitleLanguage != null && - metadata!.subtitleLanguage!.isNotEmpty) { - // Check if subtitle should be disabled (empty string or "none") - if (metadata.subtitleLanguage == 'none' || - metadata.subtitleLanguage == '') { - return null; - } - - final perMediaTrack = tracks.firstWhere( - (track) => - _matchesLanguage(track.languageCode, metadata.subtitleLanguage), - orElse: () => tracks.first, - ); - // Only use it if we actually found a matching track - if (_matchesLanguage( - perMediaTrack.languageCode, - metadata.subtitleLanguage, - )) { - return perMediaTrack; - } + SubtitleTrack? findBestSubtitleMatch( + List availableTracks, + SubtitleTrack preferred, + ) { + // Handle special "no subtitles" case + if (preferred.id == 'no') { + return SubtitleTrack.no(); } - // Priority 2: Use profile-wide subtitle preferences - // Mode 0: Manually selected - return null to disable subtitles - if (profile.autoSelectSubtitle == 0) { + return findBestTrackMatch( + availableTracks, + preferred, + (t) => t.id, + (t) => t.title, + (t) => t.language, + ); + } + + SubtitleTrack? findSubtitleTrackByProfile( + List availableTracks, + PlexUserProfile profile, { + AudioTrack? selectedAudioTrack, + }) { + appLogger.d('Subtitle track selection using user profile'); + appLogger.d( + 'Profile settings - autoSelectSubtitle: ${profile.autoSelectSubtitle}, defaultSubtitleLanguage: ${profile.defaultSubtitleLanguage}, defaultSubtitleLanguages: ${profile.defaultSubtitleLanguages}, defaultSubtitleForced: ${profile.defaultSubtitleForced}, defaultSubtitleAccessibility: ${profile.defaultSubtitleAccessibility}', + ); + + if (availableTracks.isEmpty) { + appLogger.d('Cannot use profile: No subtitle tracks available'); return null; } + // Mode 0: Manually selected - return OFF + if (profile.autoSelectSubtitle == 0) { + appLogger.d( + 'Profile specifies manual mode (autoSelectSubtitle=0) - Subtitles OFF', + ); + return SubtitleTrack.no(); + } + // Mode 1: Shown with foreign audio if (profile.autoSelectSubtitle == 1) { + appLogger.d( + 'Profile specifies foreign audio mode (autoSelectSubtitle=1)', + ); + // Check if audio language matches user's preferred subtitle language if (selectedAudioTrack != null && profile.defaultSubtitleLanguage != null) { - final audioLang = selectedAudioTrack.languageCode; - final prefLang = profile.defaultSubtitleLanguage; + final audioLang = selectedAudioTrack.language?.toLowerCase(); + final prefLang = profile.defaultSubtitleLanguage!.toLowerCase(); + final languageVariations = LanguageCodes.getVariations(prefLang); + + appLogger.d( + 'Checking if audio is foreign - audio: $audioLang, preferred subtitle lang: $prefLang', + ); // If audio matches preferred language, no subtitles needed - if (_matchesLanguage(audioLang, prefLang)) { - return null; + if (audioLang != null && languageVariations.contains(audioLang)) { + appLogger.d('Audio matches preferred language - Subtitles OFF'); + return SubtitleTrack.no(); } + appLogger.d('Foreign audio detected - enabling subtitles'); } - - // Foreign audio detected, enable subtitles - return _findBestSubtitle(tracks, profile); + // Foreign audio detected or cannot determine, enable subtitles } - // Mode 2: Always enabled - if (profile.autoSelectSubtitle == 2) { - return _findBestSubtitle(tracks, profile); - } + // Mode 2: Always enabled (or continuing from mode 1 with foreign audio) + appLogger.d('Selecting subtitle track based on preferences'); - return null; - } - - /// Finds the best subtitle track matching user preferences - static PlexSubtitleTrack? _findBestSubtitle( - List tracks, - PlexUserProfile profile, - ) { - // Build list of preferred language codes + // Build list of preferred languages final preferredLanguages = []; - if (profile.defaultSubtitleLanguage != null) { + if (profile.defaultSubtitleLanguage != null && + profile.defaultSubtitleLanguage!.isNotEmpty) { preferredLanguages.add(profile.defaultSubtitleLanguage!); } if (profile.defaultSubtitleLanguages != null) { preferredLanguages.addAll(profile.defaultSubtitleLanguages!); } - // Filter tracks based on preferences - var candidateTracks = tracks; + if (preferredLanguages.isEmpty) { + appLogger.d( + 'Cannot use profile: No defaultSubtitleLanguage(s) specified', + ); + return null; + } - // Apply SDH (hearing impaired) filtering - candidateTracks = _filterBySDH( + appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); + + // Apply filtering based on preferences + var candidateTracks = availableTracks; + + // Filter by SDH (defaultSubtitleAccessibility: 0-3) + candidateTracks = filterSubtitlesBySDH( candidateTracks, profile.defaultSubtitleAccessibility, ); - // Apply forced subtitle filtering - candidateTracks = _filterByForced( + // Filter by forced subtitle preference (defaultSubtitleForced: 0-3) + candidateTracks = filterSubtitlesByForced( candidateTracks, profile.defaultSubtitleForced, ); // If no candidates after filtering, relax filters if (candidateTracks.isEmpty) { - candidateTracks = tracks; + appLogger.d('No tracks match strict filters, relaxing filters'); + candidateTracks = availableTracks; } - // If no preferred languages, return first candidate - if (preferredLanguages.isEmpty) { - return candidateTracks.firstOrNull; - } - - // Try to find a track matching preferred languages - for (final language in preferredLanguages) { - final matchingTrack = candidateTracks.firstWhere( - (track) => _matchesLanguage(track.languageCode, language), - orElse: () => candidateTracks.first, + // Try to find track matching any preferred language + for (final preferredLanguage in preferredLanguages) { + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', ); - if (matchingTrack != candidateTracks.first || - _matchesLanguage(matchingTrack.languageCode, language)) { - return matchingTrack; + + for (var track in candidateTracks) { + final trackLang = track.language?.toLowerCase(); + if (trackLang != null && languageVariations.contains(trackLang)) { + appLogger.d( + 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', + ); + return track; + } } } - // Fallback to first candidate - return candidateTracks.firstOrNull; + appLogger.d( + 'No subtitle track found matching profile languages or their variations', + ); + return null; } - /// Filters subtitle tracks based on SDH (hearing impaired) preference + /// Filters subtitle tracks based on SDH (Subtitles for Deaf or Hard-of-Hearing) preference /// /// Values: /// - 0: Prefer non-SDH subtitles /// - 1: Prefer SDH subtitles /// - 2: Only show SDH subtitles /// - 3: Only show non-SDH subtitles - static List _filterBySDH( - List tracks, + List filterSubtitlesBySDH( + List tracks, int preference, ) { if (preference == 0 || preference == 1) { // Prefer but don't require final preferSDH = preference == 1; - final preferred = tracks.where((t) => _isSDH(t) == preferSDH).toList(); - return preferred.isNotEmpty ? preferred : tracks; + final preferred = tracks.where((t) => isSDH(t) == preferSDH).toList(); + if (preferred.isNotEmpty) { + appLogger.d( + 'Applying SDH preference: ${preferSDH ? "prefer SDH" : "prefer non-SDH"} (${preferred.length} tracks)', + ); + return preferred; + } + appLogger.d('No tracks match SDH preference, using all tracks'); + return tracks; } else if (preference == 2) { // Only SDH - return tracks.where(_isSDH).toList(); + final filtered = tracks.where((t) => isSDH(t)).toList(); + appLogger.d('Filtering to SDH only (${filtered.length} tracks)'); + return filtered; } else if (preference == 3) { // Only non-SDH - return tracks.where((t) => !_isSDH(t)).toList(); + final filtered = tracks.where((t) => !isSDH(t)).toList(); + appLogger.d('Filtering to non-SDH only (${filtered.length} tracks)'); + return filtered; } return tracks; } @@ -234,49 +319,62 @@ class TrackSelectionService { /// - 1: Prefer forced subtitles /// - 2: Only show forced subtitles /// - 3: Only show non-forced subtitles - static List _filterByForced( - List tracks, + List filterSubtitlesByForced( + List tracks, int preference, ) { if (preference == 0 || preference == 1) { // Prefer but don't require final preferForced = preference == 1; - final preferred = tracks.where((t) => t.forced == preferForced).toList(); - return preferred.isNotEmpty ? preferred : tracks; + final preferred = tracks + .where((t) => isForced(t) == preferForced) + .toList(); + if (preferred.isNotEmpty) { + appLogger.d( + 'Applying forced preference: ${preferForced ? "prefer forced" : "prefer non-forced"} (${preferred.length} tracks)', + ); + return preferred; + } + appLogger.d('No tracks match forced preference, using all tracks'); + return tracks; } else if (preference == 2) { // Only forced - return tracks.where((t) => t.forced).toList(); + final filtered = tracks.where((t) => isForced(t)).toList(); + appLogger.d('Filtering to forced only (${filtered.length} tracks)'); + return filtered; } else if (preference == 3) { // Only non-forced - return tracks.where((t) => !t.forced).toList(); + final filtered = tracks.where((t) => !isForced(t)).toList(); + appLogger.d('Filtering to non-forced only (${filtered.length} tracks)'); + return filtered; } return tracks; } /// Checks if a subtitle track is SDH (Subtitles for Deaf or Hard-of-Hearing) /// - /// Since Plex API may not expose this directly, we infer from the title/displayTitle - static bool _isSDH(PlexSubtitleTrack track) { + /// Since media_kit may not expose this directly, we infer from the title + bool isSDH(SubtitleTrack track) { final title = track.title?.toLowerCase() ?? ''; - final displayTitle = track.displayTitle?.toLowerCase() ?? ''; // Look for common SDH indicators return title.contains('sdh') || - displayTitle.contains('sdh') || title.contains('cc') || - displayTitle.contains('cc') || title.contains('hearing impaired') || - displayTitle.contains('hearing impaired'); + title.contains('deaf'); } - /// Checks if a language code matches a preferred language + /// Checks if a subtitle track is forced + bool isForced(SubtitleTrack track) { + final title = track.title?.toLowerCase() ?? ''; + return title.contains('forced'); + } + + /// Checks if a track language matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes /// Also handles bibliographic variants and region codes (e.g., "en-US") - static bool _matchesLanguage( - String? trackLanguage, - String? preferredLanguage, - ) { + bool languageMatches(String? trackLanguage, String? preferredLanguage) { if (trackLanguage == null || preferredLanguage == null) { return false; } @@ -299,4 +397,268 @@ class TrackSelectionService { // Check if track's base code matches any variation return variations.contains(trackBase); } + + /// Log available tracks for debugging + void logAvailableTracks( + List audioTracks, + List subtitleTracks, + ) { + appLogger.d('Available audio tracks: ${audioTracks.length}'); + for (var track in audioTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + appLogger.d('Available subtitle tracks: ${subtitleTracks.length}'); + for (var track in subtitleTracks) { + appLogger.d( + ' - ${track.title ?? "Track ${track.id}"} (${track.language ?? "unknown"}) ${track.isDefault == true ? "[DEFAULT]" : ""}', + ); + } + } + + /// Select the best audio track based on priority: + /// Priority 1: Preferred track from navigation + /// Priority 2: Per-media language preference + /// Priority 3: User profile preferences + /// Priority 4: Default or first track + AudioTrack? selectAudioTrack( + List availableTracks, + AudioTrack? preferredAudioTrack, + ) { + if (availableTracks.isEmpty) return null; + + AudioTrack? trackToSelect; + + // Priority 1: Try to match preferred track from navigation + if (preferredAudioTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + appLogger.d( + ' Preferred: ${preferredAudioTrack.title ?? "Track ${preferredAudioTrack.id}"} (${preferredAudioTrack.language ?? "unknown"})', + ); + trackToSelect = findBestAudioMatch(availableTracks, preferredAudioTrack); + if (trackToSelect != null) { + appLogger.d(' Matched preferred track'); + return trackToSelect; + } + appLogger.d(' No match found for preferred track'); + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred track matched, try per-media language preference + if (metadata.audioLanguage != null) { + appLogger.d('Priority 2: Checking per-media audio language preference'); + appLogger.d(' Per-media audio language: ${metadata.audioLanguage}'); + + final matchedTrack = availableTracks.firstWhere( + (track) => languageMatches(track.language, metadata.audioLanguage), + orElse: () => availableTracks.first, + ); + + if (languageMatches(matchedTrack.language, metadata.audioLanguage)) { + appLogger.d(' Matched per-media audio language preference'); + return matchedTrack; + } + appLogger.d(' No match found for per-media audio language'); + } else { + appLogger.d('Priority 2: No per-media audio language preference'); + } + + // Priority 3: If no preferred track matched, try user profile preferences + if (profileSettings != null) { + appLogger.d('Priority 3: Checking user profile preferences'); + trackToSelect = findAudioTrackByProfile( + availableTracks, + profileSettings!, + ); + if (trackToSelect != null) { + return trackToSelect; + } + } else { + appLogger.d('Priority 3: No user profile available'); + } + + // Priority 4: If no match, use default or first track + appLogger.d('Priority 4: Using default or first available track'); + trackToSelect = availableTracks.firstWhere( + (t) => t.isDefault == true, + orElse: () => availableTracks.first, + ); + final isDefault = trackToSelect.isDefault == true; + appLogger.d( + ' Selected ${isDefault ? "default" : "first"} track: ${trackToSelect.title ?? "Track ${trackToSelect.id}"} (${trackToSelect.language ?? "unknown"})', + ); + + return trackToSelect; + } + + /// Select the best subtitle track based on priority: + /// Priority 1: Preferred track from navigation + /// Priority 2: Per-media language preference + /// Priority 3: User profile preferences + /// Priority 4: Default track + /// Priority 5: Off + SubtitleTrack selectSubtitleTrack( + List availableTracks, + SubtitleTrack? preferredSubtitleTrack, + AudioTrack? selectedAudioTrack, + ) { + SubtitleTrack? subtitleToSelect; + + // Priority 1: Try preferred track from navigation (always wins) + if (preferredSubtitleTrack != null) { + appLogger.d('Priority 1: Checking preferred track from navigation'); + if (preferredSubtitleTrack.id == 'no') { + appLogger.d(' Preferred: OFF'); + return SubtitleTrack.no(); + } else if (availableTracks.isNotEmpty) { + appLogger.d( + ' Preferred: ${preferredSubtitleTrack.title ?? "Track ${preferredSubtitleTrack.id}"} (${preferredSubtitleTrack.language ?? "unknown"})', + ); + subtitleToSelect = findBestSubtitleMatch( + availableTracks, + preferredSubtitleTrack, + ); + if (subtitleToSelect != null) { + appLogger.d(' Matched preferred track'); + return subtitleToSelect; + } + appLogger.d(' No match found for preferred track'); + } + } else { + appLogger.d('Priority 1: No preferred track from navigation'); + } + + // Priority 2: If no preferred match, try per-media language preference + if (metadata.subtitleLanguage != null) { + appLogger.d( + 'Priority 2: Checking per-media subtitle language preference', + ); + appLogger.d( + ' Per-media subtitle language: ${metadata.subtitleLanguage}', + ); + + // Check if subtitle should be disabled + if (metadata.subtitleLanguage == 'none' || + metadata.subtitleLanguage!.isEmpty) { + appLogger.d(' Per-media preference: Subtitles OFF'); + return SubtitleTrack.no(); + } else if (availableTracks.isNotEmpty) { + final matchedTrack = availableTracks.firstWhere( + (track) => languageMatches(track.language, metadata.subtitleLanguage), + orElse: () => availableTracks.first, + ); + if (languageMatches(matchedTrack.language, metadata.subtitleLanguage)) { + appLogger.d(' Matched per-media subtitle language preference'); + return matchedTrack; + } + appLogger.d(' No match found for per-media subtitle language'); + } + } else { + appLogger.d('Priority 2: No per-media subtitle language preference'); + } + + // Priority 3: If no preferred match, apply user profile preferences + if (profileSettings != null && availableTracks.isNotEmpty) { + appLogger.d('Priority 3: Checking user profile preferences'); + subtitleToSelect = findSubtitleTrackByProfile( + availableTracks, + profileSettings!, + selectedAudioTrack: selectedAudioTrack, + ); + if (subtitleToSelect != null) { + return subtitleToSelect; + } + } else if (availableTracks.isNotEmpty) { + appLogger.d('Priority 3: No user profile available'); + } + + // Priority 4: If no profile match, check for default subtitle + if (availableTracks.isNotEmpty) { + appLogger.d('Priority 4: Checking for default subtitle track'); + final defaultTrack = availableTracks.firstWhere( + (t) => t.isDefault == true, + orElse: () => availableTracks.first, + ); + if (defaultTrack.isDefault == true) { + appLogger.d( + ' Found default track: ${defaultTrack.title ?? "Track ${defaultTrack.id}"} (${defaultTrack.language ?? "unknown"})', + ); + return defaultTrack; + } + appLogger.d(' No default subtitle track found'); + } + + // Priority 5: If still no subtitle selected, turn off + appLogger.d('Priority 5: No subtitle selected - Subtitles OFF'); + return SubtitleTrack.no(); + } + + /// Select and apply audio and subtitle tracks based on preferences + Future selectAndApplyTracks({ + AudioTrack? preferredAudioTrack, + SubtitleTrack? preferredSubtitleTrack, + double? preferredPlaybackRate, + Function(AudioTrack)? onAudioTrackChanged, + Function(SubtitleTrack)? onSubtitleTrackChanged, + }) async { + // Wait for tracks to be loaded + int attempts = 0; + while (player.state.tracks.audio.isEmpty && + player.state.tracks.subtitle.isEmpty && + attempts < 100) { + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + + appLogger.d('Starting track selection process'); + + // Get real tracks (excluding auto and no) + final realAudioTracks = player.state.tracks.audio + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + final realSubtitleTracks = player.state.tracks.subtitle + .where((t) => t.id != 'auto' && t.id != 'no') + .toList(); + + // Log available tracks + logAvailableTracks(realAudioTracks, realSubtitleTracks); + + // Select and apply audio track + appLogger.d('Audio track selection'); + final selectedAudioTrack = selectAudioTrack( + realAudioTracks, + preferredAudioTrack, + ); + if (selectedAudioTrack != null) { + appLogger.i( + 'Final audio selection: ${selectedAudioTrack.title ?? "Track ${selectedAudioTrack.id}"} (${selectedAudioTrack.language ?? "unknown"})', + ); + player.setAudioTrack(selectedAudioTrack); + } else { + appLogger.d('No audio tracks available'); + } + + // Select and apply subtitle track + appLogger.d('Subtitle track selection'); + final selectedSubtitleTrack = selectSubtitleTrack( + realSubtitleTracks, + preferredSubtitleTrack, + selectedAudioTrack, + ); + final finalSubtitle = selectedSubtitleTrack.id == 'no' + ? 'OFF' + : '${selectedSubtitleTrack.title ?? "Track ${selectedSubtitleTrack.id}"} (${selectedSubtitleTrack.language ?? "unknown"})'; + appLogger.i('Final subtitle selection: $finalSubtitle'); + player.setSubtitleTrack(selectedSubtitleTrack); + + // Set playback rate if preferred rate was provided + if (preferredPlaybackRate != null) { + appLogger.d('Setting preferred playback rate: ${preferredPlaybackRate}x'); + player.setRate(preferredPlaybackRate); + } + + appLogger.d('Track selection complete'); + } } diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart new file mode 100644 index 00000000..0233e704 --- /dev/null +++ b/lib/services/video_filter_manager.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../models/plex_media_version.dart'; +import '../utils/app_logger.dart'; + +/// Manages video filtering, aspect ratio modes, and subtitle positioning for video playback. +/// +/// This service handles: +/// - BoxFit mode cycling (contain → cover → fill) +/// - Video cropping calculations for fill screen mode +/// - Subtitle positioning adjustments based on crop parameters +/// - Debounced video filter updates on resize events +class VideoFilterManager { + final Player player; + final List availableVersions; + final int selectedMediaIndex; + + /// BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) + int _boxFitMode = 0; + + /// Track if a pinch gesture is occurring (public for gesture tracking) + bool isPinching = false; + + /// Current player viewport size + Size? _playerSize; + + /// Current video dimensions + Size? _videoSize; + + /// Debounce timer for resize events + Timer? _resizeDebounceTimer; + + VideoFilterManager({ + required this.player, + required this.availableVersions, + required this.selectedMediaIndex, + }); + + /// Current BoxFit mode (0=contain, 1=cover, 2=fill) + int get boxFitMode => _boxFitMode; + + /// Current player size + Size? get playerSize => _playerSize; + + /// Get current BoxFit based on mode + BoxFit get currentBoxFit { + switch (_boxFitMode) { + case 0: + return BoxFit.contain; + case 1: + return BoxFit.cover; + case 2: + return BoxFit.fill; + default: + return BoxFit.contain; + } + } + + /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) + void cycleBoxFitMode() { + _boxFitMode = (_boxFitMode + 1) % 3; + updateVideoFilter(); + } + + /// Toggle between contain and cover modes only (for pinch gesture) + void toggleContainCover() { + _boxFitMode = _boxFitMode == 0 ? 1 : 0; + updateVideoFilter(); + } + + /// Update player size when layout changes + void updatePlayerSize(Size size) { + // Check if size actually changed to avoid unnecessary updates + if (_playerSize == null || + (_playerSize!.width - size.width).abs() > 0.1 || + (_playerSize!.height - size.height).abs() > 0.1) { + _playerSize = size; + debouncedUpdateVideoFilter(); + } + } + + /// Calculates crop parameters for "fill screen" mode (BoxFit.cover) to eliminate letterboxing. + /// + /// This method is only active when [_boxFitMode] == 1 (cover mode). It determines how to + /// crop the video to completely fill the player area while maintaining aspect ratio. + /// + /// **How it works:** + /// 1. Compares video aspect ratio vs player aspect ratio + /// 2. Crops the dimension that would create letterboxing: + /// - Wide video (16:9) on tall player (4:3): crops left/right sides + /// - Tall video (4:3) on wide player (16:9): crops top/bottom + /// 3. Centers the crop within the video + /// 4. Calculates subtitle margin adjustments to keep subtitles visible + /// + /// **Subtitle positioning:** + /// MPV uses a 720p reference coordinate system for subtitle positioning. + /// When cropping zooms the video, subtitles need larger margins to avoid + /// being cropped or appearing too close to edges. + /// + /// Returns `null` if: + /// - Not in cover mode (_boxFitMode != 1) + /// - Player or video size is unknown + /// - Aspect ratios are too similar (< 0.01 difference) - no crop needed + /// + /// Returns a map containing: + /// - `width`, `height`: Dimensions of the cropped area in video pixels + /// - `x`, `y`: Crop offset from video's top-left corner in pixels + /// - `subMarginX`, `subMarginY`: Subtitle margins in MPV coordinate space (720p reference) + /// - `subScale`: Subtitle scaling factor (currently always 1.0) + Map? _calculateCropParameters() { + // Only calculate for cover mode with known dimensions + if (_boxFitMode != 1 || _playerSize == null || _videoSize == null) { + return null; + } + + final playerAspect = _playerSize!.width / _playerSize!.height; + final videoAspect = _videoSize!.width / _videoSize!.height; + + // No cropping needed if aspect ratios are very similar + if ((playerAspect - videoAspect).abs() < 0.01) return null; + + late final int cropW, cropH, cropX, cropY; + + if (videoAspect > playerAspect) { + // Video is wider than player - crop left/right sides + // Example: 16:9 video in 4:3 player + final scale = _playerSize!.height / _videoSize!.height; + cropH = _videoSize!.height.toInt(); + cropW = (_playerSize!.width / scale).toInt(); + cropX = ((_videoSize!.width - cropW) ~/ 2); // Center horizontally + cropY = 0; + } else { + // Video is taller than player - crop top/bottom + // Example: 4:3 video in 16:9 player (most common case) + final scale = _playerSize!.width / _videoSize!.width; + cropW = _videoSize!.width.toInt(); + cropH = (_playerSize!.height / scale).toInt(); + cropX = 0; + cropY = ((_videoSize!.height - cropH) ~/ 2); // Center vertically + } + + // Subtitle positioning constants + /// MPV's subtitle coordinate system height (720p reference) + const double kSubCoord = 720.0; + + /// Base horizontal subtitle margin to prevent edge clipping + const double baseX = 20.0; + + /// Base vertical subtitle margin, tuned to position subtitles + /// comfortably above the bottom while avoiding overscan areas + const double baseY = 45.0; + + // Calculate additional margin needed due to cropping + // When we crop, the visible area is "zoomed in", so subtitles need + // proportionally larger margins to maintain the same visual distance from edges + double extraX = cropX > 0 + ? (cropX / _videoSize!.width) * kSubCoord * videoAspect + : 0.0; + double extraY = cropY > 0 ? (cropY / _videoSize!.height) * kSubCoord : 0.0; + + // Apply additional margin (never reduce below base) + int marginX = (baseX + extraX).round(); + int marginY = (baseY + extraY).round(); + + return { + 'width': cropW, + 'height': cropH, + 'x': cropX, + 'y': cropY, + 'subMarginX': marginX, + 'subMarginY': marginY, + 'subScale': 1.0, + }; + } + + /// Get video dimensions from the currently selected media version + Size? _getCurrentVideoSize() { + if (availableVersions.isEmpty || + selectedMediaIndex >= availableVersions.length) { + return null; + } + + final currentVersion = availableVersions[selectedMediaIndex]; + if (currentVersion.width != null && currentVersion.height != null) { + return Size( + currentVersion.width!.toDouble(), + currentVersion.height!.toDouble(), + ); + } + + return null; + } + + /// Update the video filter based on current crop mode + void updateVideoFilter() async { + try { + final nativePlayer = player.platform as dynamic; + + if (_boxFitMode == 1) { + // Fill screen mode - apply crop filter + _videoSize = _getCurrentVideoSize(); + final cropParams = _calculateCropParameters(); + + if (cropParams != null) { + final cropFilter = + 'crop=${cropParams['width']}:${cropParams['height']}:${cropParams['x']}:${cropParams['y']}'; + appLogger.d( + 'Applying video filter: $cropFilter (player: $_playerSize, video: $_videoSize)', + ); + + // Apply crop filter + await nativePlayer.setProperty('vf', cropFilter); + + // Apply subtitle margins and scaling to compensate for crop zoom + final subMarginX = cropParams['subMarginX']!; + final subMarginY = cropParams['subMarginY']!; + final subScale = cropParams['subScale']!; + + appLogger.d( + 'Applying subtitle properties - margins: x=$subMarginX, y=$subMarginY, scale=$subScale', + ); + + await nativePlayer.setProperty('sub-margin-x', subMarginX.toString()); + await nativePlayer.setProperty('sub-margin-y', subMarginY.toString()); + await nativePlayer.setProperty('sub-scale', subScale.toString()); + } else { + // Clear filter but apply base margins if no cropping needed + appLogger.d( + 'Clearing video filter - aspect ratios similar, applying base margins (player: $_playerSize, video: $_videoSize)', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } else { + // Other modes - clear video filter but apply base margins + appLogger.d( + 'Clearing video filter, applying base margins - BoxFit mode $_boxFitMode', + ); + await nativePlayer.setProperty('vf', ''); + await nativePlayer.setProperty('sub-margin-x', '20'); // Base margin + await nativePlayer.setProperty('sub-margin-y', '40'); // Base margin + await nativePlayer.setProperty('sub-scale', '1.0'); // Reset scale + } + } catch (e) { + appLogger.w('Failed to update video filter', error: e); + } + } + + /// Debounced version of updateVideoFilter for resize events + void debouncedUpdateVideoFilter() { + _resizeDebounceTimer?.cancel(); + _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { + updateVideoFilter(); + }); + } + + /// Clean up resources + void dispose() { + _resizeDebounceTimer?.cancel(); + } +} diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart new file mode 100644 index 00000000..7380b6c8 --- /dev/null +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -0,0 +1,400 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../../models/plex_media_info.dart'; +import '../../models/plex_metadata.dart'; +import '../../services/fullscreen_state_manager.dart'; +import '../../utils/desktop_window_padding.dart'; +import '../../utils/duration_formatter.dart'; +import '../../i18n/strings.g.dart'; +import '../app_bar_back_button.dart'; +import 'painters/chapter_marker_painter.dart'; + +/// Desktop-specific video controls layout with top bar and bottom controls +class DesktopVideoControls extends StatelessWidget { + final Player player; + final PlexMetadata metadata; + final VoidCallback? onNext; + final VoidCallback? onPrevious; + final List chapters; + final bool chaptersLoaded; + final int seekTimeSmall; + final VoidCallback onSeekToPreviousChapter; + final VoidCallback onSeekToNextChapter; + final ValueChanged onSeek; + final ValueChanged onSeekEnd; + final Widget volumeControl; + final Widget trackChapterControls; + final IconData Function(int) getReplayIcon; + final IconData Function(int) getForwardIcon; + + const DesktopVideoControls({ + super.key, + required this.player, + required this.metadata, + this.onNext, + this.onPrevious, + required this.chapters, + required this.chaptersLoaded, + required this.seekTimeSmall, + required this.onSeekToPreviousChapter, + required this.onSeekToNextChapter, + required this.onSeek, + required this.onSeekEnd, + required this.volumeControl, + required this.trackChapterControls, + required this.getReplayIcon, + required this.getForwardIcon, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Top bar with back button and title + _buildTopBar(context), + const Spacer(), + // Bottom controls + _buildBottomControls(context), + ], + ); + } + + Widget _buildTopBar(BuildContext context) { + // Use global fullscreen state for padding + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + // In fullscreen on macOS, use less left padding since traffic lights auto-hide + // In normal mode on macOS, need more padding to avoid traffic lights + final leftPadding = Platform.isMacOS + ? (isFullscreen + ? DesktopWindowPadding.macOSLeftFullscreen + : DesktopWindowPadding.macOSLeft) + : DesktopWindowPadding.macOSLeftFullscreen; + + return _buildTopBarContent(context, leftPadding); + }, + ); + } + + Widget _buildTopBarContent(BuildContext context, double leftPadding) { + final topBar = Padding( + padding: EdgeInsets.only(left: leftPadding, right: 16), + child: Row( + children: [ + AppBarBackButton( + style: BackButtonStyle.video, + semanticLabel: t.videoControls.backButton, + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: Platform.isMacOS + ? _buildMacOSSingleLineTitle() + : _buildMultiLineTitle(), + ), + ], + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildMacOSSingleLineTitle() { + // Build single-line title combining series and episode info + final seriesName = metadata.grandparentTitle ?? metadata.title; + final hasEpisodeInfo = + metadata.parentIndex != null && metadata.index != null; + + final titleText = hasEpisodeInfo + ? '$seriesName · S${metadata.parentIndex} E${metadata.index} · ${metadata.title}' + : seriesName; + + return Text( + titleText, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + Widget _buildMultiLineTitle() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + metadata.grandparentTitle ?? metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (metadata.parentIndex != null && metadata.index != null) + Text( + 'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ); + } + + Widget _buildBottomControls(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + // Row 1: Timeline with time indicators + StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Row( + children: [ + Text( + formatDurationTimestamp(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildTimelineWithChapters( + position: position, + duration: duration, + ), + ), + const SizedBox(width: 12), + Text( + formatDurationTimestamp(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ); + }, + ); + }, + ), + const SizedBox(height: 4), + // Row 2: Playback controls and options + Row( + children: [ + // Previous item + Semantics( + label: t.videoControls.previousButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + Icons.skip_previous, + color: onPrevious != null ? Colors.white : Colors.white54, + ), + onPressed: onPrevious, + ), + ), + // Previous chapter (or skip backward if no chapters) + Semantics( + label: chapters.isEmpty + ? t.videoControls.seekBackwardButton(seconds: seekTimeSmall) + : t.videoControls.previousChapterButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + chapters.isEmpty + ? getReplayIcon(seekTimeSmall) + : Icons.fast_rewind, + color: Colors.white, + ), + onPressed: onSeekToPreviousChapter, + ), + ), + // Play/Pause + StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return Semantics( + label: isPlaying + ? t.videoControls.pauseButton + : t.videoControls.playButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 32, + ), + iconSize: 32, + onPressed: () { + if (isPlaying) { + player.pause(); + } else { + player.play(); + } + }, + ), + ); + }, + ), + // Next chapter (or skip forward if no chapters) + Semantics( + label: chapters.isEmpty + ? t.videoControls.seekForwardButton(seconds: seekTimeSmall) + : t.videoControls.nextChapterButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + chapters.isEmpty + ? getForwardIcon(seekTimeSmall) + : Icons.fast_forward, + color: Colors.white, + ), + onPressed: onSeekToNextChapter, + ), + ), + // Next item + Semantics( + label: t.videoControls.nextButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + Icons.skip_next, + color: onNext != null ? Colors.white : Colors.white54, + ), + onPressed: onNext, + ), + ), + const Spacer(), + // Volume control + volumeControl, + const SizedBox(width: 16), + // Audio track, subtitle, and chapter controls + trackChapterControls, + ], + ), + ], + ), + ); + } + + Widget _buildTimelineWithChapters({ + required Duration position, + required Duration duration, + }) { + return Stack( + alignment: Alignment.center, + children: [ + // Chapter markers layer + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: + chapters.map((chapter) { + final chapterPosition = + (chapter.startTimeOffset ?? 0) / + duration.inMilliseconds; + return Expanded( + flex: (chapterPosition * 1000).toInt(), + child: const SizedBox(), + ); + }).toList()..add( + Expanded( + flex: + 1000 - + chapters.fold( + 0, + (sum, chapter) => + sum + + ((chapter.startTimeOffset ?? 0) / + duration.inMilliseconds * + 1000) + .toInt(), + ), + child: const SizedBox(), + ), + ), + ), + ), + ), + // Slider + Semantics( + label: t.videoControls.timelineSlider, + slider: true, + child: Slider( + value: duration.inMilliseconds > 0 + ? position.inMilliseconds.toDouble() + : 0.0, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + onSeek(Duration(milliseconds: value.toInt())); + }, + onChangeEnd: (value) { + onSeekEnd(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + // Chapter marker indicators + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter( + chapters: chapters, + duration: duration, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart new file mode 100644 index 00000000..62eab711 --- /dev/null +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -0,0 +1,420 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../../models/plex_media_info.dart'; +import '../../models/plex_metadata.dart'; +import '../../utils/duration_formatter.dart'; +import '../../i18n/strings.g.dart'; +import '../app_bar_back_button.dart'; +import 'painters/chapter_marker_painter.dart'; + +/// Mobile video controls layout for Plex video player +/// +/// Displays a full-screen overlay with: +/// - Top bar: Back button, title, and track/chapter controls +/// - Center: Large playback controls (seek backward, play/pause, seek forward) +/// - Bottom bar: Timeline slider with chapter markers and timestamps +class MobileVideoControls extends StatelessWidget { + final Player player; + final PlexMetadata metadata; + final List chapters; + final bool chaptersLoaded; + final int seekTimeSmall; + final Widget trackChapterControls; + final Function(Duration) onSeek; + final Function(Duration) onSeekEnd; + final VoidCallback onPlayPause; + final VoidCallback? onCancelAutoHide; + final VoidCallback? onStartAutoHide; + + const MobileVideoControls({ + super.key, + required this.player, + required this.metadata, + required this.chapters, + required this.chaptersLoaded, + required this.seekTimeSmall, + required this.trackChapterControls, + required this.onSeek, + required this.onSeekEnd, + required this.onPlayPause, + this.onCancelAutoHide, + this.onStartAutoHide, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Top bar with back button and track/chapter controls + _buildTopBar(context), + const Spacer(), + // Centered large playback controls + _buildPlaybackControls(context), + const Spacer(), + // Progress bar at bottom + _buildBottomBar(context), + ], + ); + } + + Widget _buildTopBar(BuildContext context) { + final topBar = _conditionalSafeArea( + context: context, + bottom: false, // Only respect top safe area when in portrait + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + AppBarBackButton( + style: BackButtonStyle.video, + semanticLabel: t.videoControls.backButton, + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + metadata.grandparentTitle ?? metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (metadata.parentIndex != null && metadata.index != null) + Text( + 'S${metadata.parentIndex} · E${metadata.index} · ${metadata.title}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Track and chapter controls in top right + trackChapterControls, + ], + ), + ), + ); + + // On macOS, wrap with GestureDetector to prevent window dragging + if (Platform.isMacOS) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) {}, // Consume pan gestures to prevent window dragging + child: topBar, + ); + } + + return topBar; + } + + Widget _buildPlaybackControls(BuildContext context) { + return StreamBuilder( + stream: player.stream.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Semantics( + label: t.videoControls.seekBackwardButton( + seconds: seekTimeSmall, + ), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + _getReplayIcon(seekTimeSmall), + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + _seekWithClamping(Duration(seconds: -seekTimeSmall)); + }, + ), + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Semantics( + label: isPlaying + ? t.videoControls.pauseButton + : t.videoControls.playButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 72, + ), + iconSize: 72, + onPressed: () { + if (isPlaying) { + player.pause(); + onCancelAutoHide?.call(); // Cancel auto-hide when paused + } else { + player.play(); + onStartAutoHide?.call(); // Start auto-hide when playing + } + }, + ), + ), + ), + const SizedBox(width: 48), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Semantics( + label: t.videoControls.seekForwardButton( + seconds: seekTimeSmall, + ), + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + _getForwardIcon(seekTimeSmall), + color: Colors.white, + size: 48, + ), + iconSize: 48, + onPressed: () { + _seekWithClamping(Duration(seconds: seekTimeSmall)); + }, + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildBottomBar(BuildContext context) { + return _conditionalSafeArea( + context: context, + top: false, // Only respect bottom safe area when in portrait + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: player.stream.position, + initialData: player.state.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: player.stream.duration, + initialData: player.state.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; + + return Column( + children: [ + _buildTimelineWithChapters( + position: position, + duration: duration, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + formatDurationTimestamp(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + Text( + formatDurationTimestamp(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } + + Widget _buildTimelineWithChapters({ + required Duration position, + required Duration duration, + }) { + return Stack( + alignment: Alignment.center, + children: [ + // Chapter markers layer + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: + chapters.map((chapter) { + final chapterPosition = + (chapter.startTimeOffset ?? 0) / + duration.inMilliseconds; + return Expanded( + flex: (chapterPosition * 1000).toInt(), + child: const SizedBox(), + ); + }).toList()..add( + Expanded( + flex: + 1000 - + chapters.fold( + 0, + (sum, chapter) => + sum + + ((chapter.startTimeOffset ?? 0) / + duration.inMilliseconds * + 1000) + .toInt(), + ), + child: const SizedBox(), + ), + ), + ), + ), + ), + // Slider + Semantics( + label: t.videoControls.timelineSlider, + slider: true, + child: Slider( + value: duration.inMilliseconds > 0 + ? position.inMilliseconds.toDouble() + : 0.0, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + onSeek(Duration(milliseconds: value.toInt())); + }, + onChangeEnd: (value) { + onSeekEnd(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + // Chapter marker indicators + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter( + chapters: chapters, + duration: duration, + ), + ), + ), + ), + ), + ], + ); + } + + /// Conditionally wraps child with SafeArea only in portrait mode + Widget _conditionalSafeArea({ + required BuildContext context, + required Widget child, + bool top = true, + bool bottom = true, + }) { + final orientation = MediaQuery.of(context).orientation; + final isPortrait = orientation == Orientation.portrait; + + // Only apply SafeArea in portrait mode + if (isPortrait) { + return SafeArea(top: top, bottom: bottom, child: child); + } + + // In landscape, return child without SafeArea + return child; + } + + void _seekWithClamping(Duration offset) { + final currentPosition = player.state.position; + final duration = player.state.duration; + final newPosition = currentPosition + offset; + + // Clamp between 0 and video duration + final clampedPosition = newPosition.isNegative + ? Duration.zero + : (newPosition > duration ? duration : newPosition); + + player.seek(clampedPosition); + } + + /// Get the replay icon based on the duration + /// Returns numbered icons (replay_5, replay_10, replay_30) when available, + /// otherwise returns generic replay icon + IconData _getReplayIcon(int seconds) { + switch (seconds) { + case 5: + return Icons.replay_5; + case 10: + return Icons.replay_10; + case 30: + return Icons.replay_30; + default: + return Icons.replay; // Generic icon for custom durations + } + } + + /// Get the forward icon based on the duration + /// Returns numbered icons (forward_5, forward_10, forward_30) when available, + /// otherwise returns generic forward icon + IconData _getForwardIcon(int seconds) { + switch (seconds) { + case 5: + return Icons.forward_5; + case 10: + return Icons.forward_10; + case 30: + return Icons.forward_30; + default: + return Icons.forward; // Generic icon for custom durations + } + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 50b6f32f..813e1701 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -5,34 +5,22 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:media_kit/media_kit.dart'; -import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import '../../client/plex_client.dart'; import '../../models/plex_media_info.dart'; import '../../models/plex_media_version.dart'; import '../../models/plex_metadata.dart'; -import '../../providers/plex_client_provider.dart'; -import '../../providers/multi_server_provider.dart'; import '../../screens/video_player_screen.dart'; -import '../../services/fullscreen_state_manager.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; -import '../../services/sleep_timer_service.dart'; -import '../../utils/app_logger.dart'; -import '../../utils/desktop_window_padding.dart'; -import '../../utils/duration_formatter.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../i18n/strings.g.dart'; -import '../app_bar_back_button.dart'; -import 'painters/chapter_marker_painter.dart'; -import 'sheets/audio_track_sheet.dart'; -import 'sheets/chapter_sheet.dart'; -import 'sheets/subtitle_track_sheet.dart'; -import 'sheets/version_sheet.dart'; -import 'sheets/video_settings_sheet.dart'; -import 'video_control_button.dart'; +import 'widgets/volume_control.dart'; +import 'widgets/track_chapter_controls.dart'; +import 'mobile_video_controls.dart'; +import 'desktop_video_controls.dart'; /// Custom video controls builder for Plex with chapter, audio, and subtitle support Widget plexVideoControlsBuilder( @@ -381,183 +369,30 @@ class _PlexVideoControlsState extends State } } - bool _hasMultipleAudioTracks(Tracks? tracks) { - if (tracks == null) return false; - final audioTracks = tracks.audio - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - return audioTracks.length > 1; - } - - bool _hasSubtitles(Tracks? tracks) { - if (tracks == null) return false; - final subtitles = tracks.subtitle - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - return subtitles.isNotEmpty; - } - - IconData _getBoxFitIcon(int mode) { - switch (mode) { - case 0: - return Icons.fit_screen; // contain (letterbox) - case 1: - return Icons.aspect_ratio; // cover (fill screen) - case 2: - return Icons.settings_overscan; // fill (stretch) - default: - return Icons.fit_screen; - } - } - - String _getBoxFitTooltip(int mode) { - switch (mode) { - case 0: - return t.videoControls.letterbox; - case 1: - return t.videoControls.fillScreen; - case 2: - return t.videoControls.stretch; - default: - return t.videoControls.letterbox; - } - } - - /// Conditionally wraps child with SafeArea only in portrait mode - Widget _conditionalSafeArea({ - required Widget child, - bool top = true, - bool bottom = true, - }) { - final orientation = MediaQuery.of(context).orientation; - final isPortrait = orientation == Orientation.portrait; - - // Only apply SafeArea in portrait mode - if (isPortrait) { - return SafeArea(top: top, bottom: bottom, child: child); - } - - // In landscape, return child without SafeArea - return child; - } - - Widget _buildTrackAndChapterControls() { - return StreamBuilder( - stream: widget.player.stream.tracks, - initialData: widget.player.state.tracks, - builder: (context, snapshot) { - final tracks = snapshot.data; - return IntrinsicHeight( - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Unified settings button (speed, sleep timer, audio sync, subtitle sync) - ListenableBuilder( - listenable: SleepTimerService(), - builder: (context, _) { - final sleepTimer = SleepTimerService(); - final isActive = - sleepTimer.isActive || - _audioSyncOffset != 0 || - _subtitleSyncOffset != 0; - return VideoControlButton( - icon: Icons.tune, - isActive: isActive, - semanticLabel: t.videoControls.settingsButton, - onPressed: () async { - await VideoSettingsSheet.show( - context, - widget.player, - _audioSyncOffset, - _subtitleSyncOffset, - ); - // Sheet is now closed, reload immediately - if (mounted) { - await _loadSeekTimes(); - } - }, - ); - }, - ), - if (_hasMultipleAudioTracks(tracks)) - VideoControlButton( - icon: Icons.audiotrack, - semanticLabel: t.videoControls.audioTrackButton, - onPressed: () => AudioTrackSheet.show( - context, - widget.player, - onTrackChanged: widget.onAudioTrackChanged, - ), - ), - if (_hasSubtitles(tracks)) - VideoControlButton( - icon: Icons.subtitles, - semanticLabel: t.videoControls.subtitlesButton, - onPressed: () => SubtitleTrackSheet.show( - context, - widget.player, - onTrackChanged: widget.onSubtitleTrackChanged, - ), - ), - if (_chapters.isNotEmpty) - VideoControlButton( - icon: Icons.video_library, - semanticLabel: t.videoControls.chaptersButton, - onPressed: () => ChapterSheet.show( - context, - widget.player, - _chapters, - _chaptersLoaded, - serverId: widget.metadata.serverId, - ), - ), - if (widget.availableVersions.length > 1) - VideoControlButton( - icon: Icons.video_file, - semanticLabel: t.videoControls.versionsButton, - onPressed: () => VersionSheet.show( - context, - widget.availableVersions, - widget.selectedMediaIndex, - _switchMediaVersion, - ), - ), - // BoxFit mode cycle button - if (widget.onCycleBoxFitMode != null) - VideoControlButton( - icon: _getBoxFitIcon(widget.boxFitMode), - tooltip: _getBoxFitTooltip(widget.boxFitMode), - semanticLabel: t.videoControls.aspectRatioButton, - onPressed: widget.onCycleBoxFitMode, - ), - // Rotation lock toggle (mobile only) - if (PlatformDetector.isMobile(context)) - VideoControlButton( - icon: _isRotationLocked - ? Icons.screen_lock_rotation - : Icons.screen_rotation, - tooltip: _isRotationLocked - ? t.videoControls.unlockRotation - : t.videoControls.lockRotation, - semanticLabel: t.videoControls.rotationLockButton, - onPressed: _toggleRotationLock, - ), - // Fullscreen toggle (desktop only) - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) - VideoControlButton( - icon: _isFullscreen - ? Icons.fullscreen_exit - : Icons.fullscreen, - semanticLabel: _isFullscreen - ? t.videoControls.exitFullscreenButton - : t.videoControls.fullscreenButton, - onPressed: _toggleFullscreen, - ), - ], - ), - ); + Widget _buildTrackChapterControlsWidget() { + return TrackChapterControls( + player: widget.player, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + availableVersions: widget.availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: widget.boxFitMode, + audioSyncOffset: _audioSyncOffset, + subtitleSyncOffset: _subtitleSyncOffset, + isRotationLocked: _isRotationLocked, + isFullscreen: _isFullscreen, + onCycleBoxFitMode: widget.onCycleBoxFitMode, + onToggleRotationLock: _toggleRotationLock, + onToggleFullscreen: _toggleFullscreen, + onSwitchVersion: _switchMediaVersion, + onAudioTrackChanged: widget.onAudioTrackChanged, + onSubtitleTrackChanged: widget.onSubtitleTrackChanged, + onLoadSeekTimes: () async { + if (mounted) { + await _loadSeekTimes(); + } }, + serverId: widget.metadata.serverId ?? '', ); } @@ -846,8 +681,41 @@ class _PlexVideoControlsState extends State ), ), child: isMobile - ? _buildMobileLayout() - : _buildDesktopLayout(), + ? MobileVideoControls( + player: widget.player, + metadata: widget.metadata, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + trackChapterControls: + _buildTrackChapterControlsWidget(), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + onPlayPause: + () {}, // Not used, handled internally + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + ) + : DesktopVideoControls( + player: widget.player, + metadata: widget.metadata, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + volumeControl: VolumeControl( + player: widget.player, + ), + trackChapterControls: + _buildTrackChapterControlsWidget(), + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + getReplayIcon: _getReplayIcon, + getForwardIcon: _getForwardIcon, + ), ), ), ), @@ -1013,661 +881,6 @@ class _PlexVideoControlsState extends State ); } - Widget _buildMobileLayout() { - return Column( - children: [ - // Top bar with back button and track/chapter controls - _buildMobileTopBar(), - const Spacer(), - // Centered large playback controls - _buildMobilePlaybackControls(), - const Spacer(), - // Progress bar at bottom - _buildMobileBottomBar(), - ], - ); - } - - Widget _buildDesktopLayout() { - return Column( - children: [ - // Top bar with back button and title - _buildDesktopTopBar(), - const Spacer(), - // Bottom controls - _buildDesktopBottomControls(), - ], - ); - } - - // Mobile layout components - Widget _buildMobileTopBar() { - final topBar = _conditionalSafeArea( - bottom: false, // Only respect top safe area when in portrait - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - AppBarBackButton( - style: BackButtonStyle.video, - semanticLabel: t.videoControls.backButton, - onPressed: () => Navigator.of(context).pop(true), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.metadata.grandparentTitle ?? widget.metadata.title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (widget.metadata.parentIndex != null && - widget.metadata.index != null) - Text( - 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // Track and chapter controls in top right - _buildTrackAndChapterControls(), - ], - ), - ), - ); - - // On macOS, wrap with GestureDetector to prevent window dragging - if (Platform.isMacOS) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: topBar, - ); - } - - return topBar; - } - - Widget _buildMobilePlaybackControls() { - return StreamBuilder( - stream: widget.player.stream.playing, - initialData: widget.player.state.playing, - builder: (context, snapshot) { - final isPlaying = snapshot.data ?? false; - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: Semantics( - label: t.videoControls.seekBackwardButton( - seconds: _seekTimeSmall, - ), - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - _getReplayIcon(_seekTimeSmall), - color: Colors.white, - size: 48, - ), - iconSize: 48, - onPressed: () { - _seekWithClamping(Duration(seconds: -_seekTimeSmall)); - }, - ), - ), - ), - const SizedBox(width: 48), - Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: Semantics( - label: isPlaying - ? t.videoControls.pauseButton - : t.videoControls.playButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 72, - ), - iconSize: 72, - onPressed: () { - if (isPlaying) { - widget.player.pause(); - _hideTimer?.cancel(); // Cancel auto-hide when paused - } else { - widget.player.play(); - _startHideTimer(); // Start auto-hide when playing - } - }, - ), - ), - ), - const SizedBox(width: 48), - Container( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: Semantics( - label: t.videoControls.seekForwardButton( - seconds: _seekTimeSmall, - ), - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - _getForwardIcon(_seekTimeSmall), - color: Colors.white, - size: 48, - ), - iconSize: 48, - onPressed: () { - _seekWithClamping(Duration(seconds: _seekTimeSmall)); - }, - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildMobileBottomBar() { - return _conditionalSafeArea( - top: false, // Only respect bottom safe area when in portrait - child: Padding( - padding: const EdgeInsets.all(16), - child: StreamBuilder( - stream: widget.player.stream.position, - initialData: widget.player.state.position, - builder: (context, positionSnapshot) { - return StreamBuilder( - stream: widget.player.stream.duration, - initialData: widget.player.state.duration, - builder: (context, durationSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final duration = durationSnapshot.data ?? Duration.zero; - - return Column( - children: [ - _buildTimelineWithChapters( - position: position, - duration: duration, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - formatDurationTimestamp(position), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - Text( - formatDurationTimestamp(duration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ), - ), - ], - ); - }, - ); - }, - ), - ), - ); - } - - // Desktop layout components - Widget _buildDesktopTopBar() { - // Use global fullscreen state for padding - return ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - // In fullscreen on macOS, use less left padding since traffic lights auto-hide - // In normal mode on macOS, need more padding to avoid traffic lights - final leftPadding = Platform.isMacOS - ? (isFullscreen - ? DesktopWindowPadding.macOSLeftFullscreen - : DesktopWindowPadding.macOSLeft) - : DesktopWindowPadding.macOSLeftFullscreen; - - return _buildDesktopTopBarContent(leftPadding); - }, - ); - } - - Widget _buildDesktopTopBarContent(double leftPadding) { - final topBar = Padding( - padding: EdgeInsets.only(left: leftPadding, right: 16), - child: Row( - children: [ - AppBarBackButton( - style: BackButtonStyle.video, - semanticLabel: t.videoControls.backButton, - onPressed: () => Navigator.of(context).pop(true), - ), - const SizedBox(width: 16), - Expanded( - child: Platform.isMacOS - ? _buildMacOSSingleLineTitle() - : _buildMultiLineTitle(), - ), - ], - ), - ); - - // On macOS, wrap with GestureDetector to prevent window dragging - if (Platform.isMacOS) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: topBar, - ); - } - - return topBar; - } - - Widget _buildMacOSSingleLineTitle() { - // Build single-line title combining series and episode info - final seriesName = - widget.metadata.grandparentTitle ?? widget.metadata.title; - final hasEpisodeInfo = - widget.metadata.parentIndex != null && widget.metadata.index != null; - - final titleText = hasEpisodeInfo - ? '$seriesName · S${widget.metadata.parentIndex} E${widget.metadata.index} · ${widget.metadata.title}' - : seriesName; - - return Text( - titleText, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } - - Widget _buildMultiLineTitle() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.metadata.grandparentTitle ?? widget.metadata.title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (widget.metadata.parentIndex != null && - widget.metadata.index != null) - Text( - 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', - style: const TextStyle(color: Colors.white70, fontSize: 14), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - } - - Widget _buildDesktopBottomControls() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Column( - children: [ - // Row 1: Timeline with time indicators - StreamBuilder( - stream: widget.player.stream.position, - initialData: widget.player.state.position, - builder: (context, positionSnapshot) { - return StreamBuilder( - stream: widget.player.stream.duration, - initialData: widget.player.state.duration, - builder: (context, durationSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final duration = durationSnapshot.data ?? Duration.zero; - - return Row( - children: [ - Text( - formatDurationTimestamp(position), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildTimelineWithChapters( - position: position, - duration: duration, - ), - ), - const SizedBox(width: 12), - Text( - formatDurationTimestamp(duration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ); - }, - ); - }, - ), - const SizedBox(height: 4), - // Row 2: Playback controls and options - Row( - children: [ - // Previous item - Semantics( - label: t.videoControls.previousButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - Icons.skip_previous, - color: widget.onPrevious != null - ? Colors.white - : Colors.white54, - ), - onPressed: widget.onPrevious, - ), - ), - // Previous chapter (or skip backward if no chapters) - Semantics( - label: _chapters.isEmpty - ? t.videoControls.seekBackwardButton( - seconds: _seekTimeSmall, - ) - : t.videoControls.previousChapterButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - _chapters.isEmpty - ? _getReplayIcon(_seekTimeSmall) - : Icons.fast_rewind, - color: Colors.white, - ), - onPressed: _seekToPreviousChapter, - ), - ), - // Play/Pause - StreamBuilder( - stream: widget.player.stream.playing, - initialData: widget.player.state.playing, - builder: (context, snapshot) { - final isPlaying = snapshot.data ?? false; - return Semantics( - label: isPlaying - ? t.videoControls.pauseButton - : t.videoControls.playButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 32, - ), - iconSize: 32, - onPressed: () { - if (isPlaying) { - widget.player.pause(); - _hideTimer?.cancel(); // Cancel auto-hide when paused - } else { - widget.player.play(); - _startHideTimer(); // Start auto-hide when playing - } - }, - ), - ); - }, - ), - // Next chapter (or skip forward if no chapters) - Semantics( - label: _chapters.isEmpty - ? t.videoControls.seekForwardButton(seconds: _seekTimeSmall) - : t.videoControls.nextChapterButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - _chapters.isEmpty - ? _getForwardIcon(_seekTimeSmall) - : Icons.fast_forward, - color: Colors.white, - ), - onPressed: _seekToNextChapter, - ), - ), - // Next item - Semantics( - label: t.videoControls.nextButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - Icons.skip_next, - color: widget.onNext != null - ? Colors.white - : Colors.white54, - ), - onPressed: widget.onNext, - ), - ), - const Spacer(), - // Volume control - _buildVolumeControl(), - const SizedBox(width: 16), - // Audio track, subtitle, and chapter controls - _buildTrackAndChapterControls(), - ], - ), - ], - ), - ); - } - - Widget _buildTimelineWithChapters({ - required Duration position, - required Duration duration, - }) { - return Stack( - alignment: Alignment.center, - children: [ - // Chapter markers layer - if (_chaptersLoaded && - _chapters.isNotEmpty && - duration.inMilliseconds > 0) - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - children: - _chapters.map((chapter) { - final chapterPosition = - (chapter.startTimeOffset ?? 0) / - duration.inMilliseconds; - return Expanded( - flex: (chapterPosition * 1000).toInt(), - child: const SizedBox(), - ); - }).toList()..add( - Expanded( - flex: - 1000 - - _chapters.fold( - 0, - (sum, chapter) => - sum + - ((chapter.startTimeOffset ?? 0) / - duration.inMilliseconds * - 1000) - .toInt(), - ), - child: const SizedBox(), - ), - ), - ), - ), - ), - // Slider - Semantics( - label: t.videoControls.timelineSlider, - slider: true, - child: Slider( - value: duration.inMilliseconds > 0 - ? position.inMilliseconds.toDouble() - : 0.0, - min: 0.0, - max: duration.inMilliseconds.toDouble(), - onChanged: (value) { - _throttledSeek(Duration(milliseconds: value.toInt())); - }, - onChangeEnd: (value) { - _finalizeSeek(Duration(milliseconds: value.toInt())); - }, - activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), - ), - ), - // Chapter marker indicators - if (_chaptersLoaded && - _chapters.isNotEmpty && - duration.inMilliseconds > 0) - Positioned.fill( - child: IgnorePointer( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: CustomPaint( - painter: ChapterMarkerPainter( - chapters: _chapters, - duration: duration, - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildVolumeControl() { - return StreamBuilder( - stream: widget.player.stream.volume, - initialData: widget.player.state.volume, - builder: (context, snapshot) { - final volume = snapshot.data ?? 100.0; - final isMuted = volume == 0; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - label: isMuted - ? t.videoControls.unmuteButton - : t.videoControls.muteButton, - button: true, - excludeSemantics: true, - child: IconButton( - icon: Icon( - isMuted ? Icons.volume_off : Icons.volume_up, - color: Colors.white, - ), - onPressed: () async { - final newVolume = isMuted ? 100.0 : 0.0; - widget.player.setVolume(newVolume); - final settings = await SettingsService.getInstance(); - await settings.setVolume(newVolume); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 100, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 3, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 6, - ), - overlayShape: const RoundSliderOverlayShape( - overlayRadius: 12, - ), - ), - child: Semantics( - label: t.videoControls.volumeSlider, - slider: true, - child: Slider( - value: volume, - min: 0.0, - max: 100.0, - onChanged: (value) { - widget.player.setVolume(value); - }, - onChangeEnd: (value) async { - final settings = await SettingsService.getInstance(); - await settings.setVolume(value); - }, - activeColor: Colors.white, - inactiveColor: Colors.white.withValues(alpha: 0.3), - ), - ), - ), - ), - ], - ); - }, - ); - } - /// Switch to a different media version Future _switchMediaVersion(int newMediaIndex) async { if (newMediaIndex == widget.selectedMediaIndex) { diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart new file mode 100644 index 00000000..d480c9c3 --- /dev/null +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../../../models/plex_media_info.dart'; +import '../../../i18n/strings.g.dart'; +import '../painters/chapter_marker_painter.dart'; + +/// Timeline slider with chapter markers for video playback +/// +/// Displays a horizontal slider showing playback position and duration, +/// with optional chapter markers overlaid at their respective positions. +class TimelineSlider extends StatelessWidget { + final Duration position; + final Duration duration; + final List chapters; + final bool chaptersLoaded; + final ValueChanged onSeek; + final ValueChanged onSeekEnd; + + const TimelineSlider({ + super.key, + required this.position, + required this.duration, + required this.chapters, + required this.chaptersLoaded, + required this.onSeek, + required this.onSeekEnd, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + // Chapter markers layer + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: + chapters.map((chapter) { + final chapterPosition = + (chapter.startTimeOffset ?? 0) / + duration.inMilliseconds; + return Expanded( + flex: (chapterPosition * 1000).toInt(), + child: const SizedBox(), + ); + }).toList()..add( + Expanded( + flex: + 1000 - + chapters.fold( + 0, + (sum, chapter) => + sum + + ((chapter.startTimeOffset ?? 0) / + duration.inMilliseconds * + 1000) + .toInt(), + ), + child: const SizedBox(), + ), + ), + ), + ), + ), + // Slider + Semantics( + label: t.videoControls.timelineSlider, + slider: true, + child: Slider( + value: duration.inMilliseconds > 0 + ? position.inMilliseconds.toDouble() + : 0.0, + min: 0.0, + max: duration.inMilliseconds.toDouble(), + onChanged: (value) { + onSeek(Duration(milliseconds: value.toInt())); + }, + onChangeEnd: (value) { + onSeekEnd(Duration(milliseconds: value.toInt())); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + // Chapter marker indicators + if (chaptersLoaded && + chapters.isNotEmpty && + duration.inMilliseconds > 0) + Positioned.fill( + child: IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: CustomPaint( + painter: ChapterMarkerPainter( + chapters: chapters, + duration: duration, + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart new file mode 100644 index 00000000..ca2a8f6a --- /dev/null +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -0,0 +1,219 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../../../models/plex_media_info.dart'; +import '../../../models/plex_media_version.dart'; +import '../../../services/sleep_timer_service.dart'; +import '../../../utils/platform_detector.dart'; +import '../../../i18n/strings.g.dart'; +import '../sheets/audio_track_sheet.dart'; +import '../sheets/chapter_sheet.dart'; +import '../sheets/subtitle_track_sheet.dart'; +import '../sheets/version_sheet.dart'; +import '../sheets/video_settings_sheet.dart'; +import '../video_control_button.dart'; + +/// Row of track and chapter control buttons for the video player +class TrackChapterControls extends StatelessWidget { + final Player player; + final List chapters; + final bool chaptersLoaded; + final List availableVersions; + final int selectedMediaIndex; + final int boxFitMode; + final int audioSyncOffset; + final int subtitleSyncOffset; + final bool isRotationLocked; + final bool isFullscreen; + final VoidCallback? onCycleBoxFitMode; + final VoidCallback? onToggleRotationLock; + final VoidCallback? onToggleFullscreen; + final Function(int)? onSwitchVersion; + final Function(AudioTrack)? onAudioTrackChanged; + final Function(SubtitleTrack)? onSubtitleTrackChanged; + final VoidCallback? onLoadSeekTimes; + final String serverId; + + const TrackChapterControls({ + super.key, + required this.player, + required this.chapters, + required this.chaptersLoaded, + required this.availableVersions, + required this.selectedMediaIndex, + required this.boxFitMode, + required this.audioSyncOffset, + required this.subtitleSyncOffset, + required this.isRotationLocked, + required this.isFullscreen, + required this.serverId, + this.onCycleBoxFitMode, + this.onToggleRotationLock, + this.onToggleFullscreen, + this.onSwitchVersion, + this.onAudioTrackChanged, + this.onSubtitleTrackChanged, + this.onLoadSeekTimes, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: player.stream.tracks, + initialData: player.state.tracks, + builder: (context, snapshot) { + final tracks = snapshot.data; + return IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Unified settings button (speed, sleep timer, audio sync, subtitle sync) + ListenableBuilder( + listenable: SleepTimerService(), + builder: (context, _) { + final sleepTimer = SleepTimerService(); + final isActive = + sleepTimer.isActive || + audioSyncOffset != 0 || + subtitleSyncOffset != 0; + return VideoControlButton( + icon: Icons.tune, + isActive: isActive, + semanticLabel: t.videoControls.settingsButton, + onPressed: () async { + await VideoSettingsSheet.show( + context, + player, + audioSyncOffset, + subtitleSyncOffset, + ); + // Sheet is now closed, reload immediately + onLoadSeekTimes?.call(); + }, + ); + }, + ), + if (_hasMultipleAudioTracks(tracks)) + VideoControlButton( + icon: Icons.audiotrack, + semanticLabel: t.videoControls.audioTrackButton, + onPressed: () => AudioTrackSheet.show( + context, + player, + onTrackChanged: onAudioTrackChanged, + ), + ), + if (_hasSubtitles(tracks)) + VideoControlButton( + icon: Icons.subtitles, + semanticLabel: t.videoControls.subtitlesButton, + onPressed: () => SubtitleTrackSheet.show( + context, + player, + onTrackChanged: onSubtitleTrackChanged, + ), + ), + if (chapters.isNotEmpty) + VideoControlButton( + icon: Icons.video_library, + semanticLabel: t.videoControls.chaptersButton, + onPressed: () => ChapterSheet.show( + context, + player, + chapters, + chaptersLoaded, + serverId: serverId, + ), + ), + if (availableVersions.length > 1 && onSwitchVersion != null) + VideoControlButton( + icon: Icons.video_file, + semanticLabel: t.videoControls.versionsButton, + onPressed: () => VersionSheet.show( + context, + availableVersions, + selectedMediaIndex, + onSwitchVersion!, + ), + ), + // BoxFit mode cycle button + if (onCycleBoxFitMode != null) + VideoControlButton( + icon: _getBoxFitIcon(boxFitMode), + tooltip: _getBoxFitTooltip(boxFitMode), + semanticLabel: t.videoControls.aspectRatioButton, + onPressed: onCycleBoxFitMode, + ), + // Rotation lock toggle (mobile only) + if (PlatformDetector.isMobile(context)) + VideoControlButton( + icon: isRotationLocked + ? Icons.screen_lock_rotation + : Icons.screen_rotation, + tooltip: isRotationLocked + ? t.videoControls.unlockRotation + : t.videoControls.lockRotation, + semanticLabel: t.videoControls.rotationLockButton, + onPressed: onToggleRotationLock, + ), + // Fullscreen toggle (desktop only) + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) + VideoControlButton( + icon: isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, + semanticLabel: isFullscreen + ? t.videoControls.exitFullscreenButton + : t.videoControls.fullscreenButton, + onPressed: onToggleFullscreen, + ), + ], + ), + ); + }, + ); + } + + bool _hasMultipleAudioTracks(Tracks? tracks) { + if (tracks == null) return false; + final audioTracks = tracks.audio + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + return audioTracks.length > 1; + } + + bool _hasSubtitles(Tracks? tracks) { + if (tracks == null) return false; + final subtitles = tracks.subtitle + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + return subtitles.isNotEmpty; + } + + IconData _getBoxFitIcon(int mode) { + switch (mode) { + case 0: + return Icons.fit_screen; // contain (letterbox) + case 1: + return Icons.aspect_ratio; // cover (fill screen) + case 2: + return Icons.settings_overscan; // fill (stretch) + default: + return Icons.fit_screen; + } + } + + String _getBoxFitTooltip(int mode) { + switch (mode) { + case 0: + return t.videoControls.letterbox; + case 1: + return t.videoControls.fillScreen; + case 2: + return t.videoControls.stretch; + default: + return t.videoControls.letterbox; + } + } +} diff --git a/lib/widgets/video_controls/widgets/volume_control.dart b/lib/widgets/video_controls/widgets/volume_control.dart new file mode 100644 index 00000000..95157af8 --- /dev/null +++ b/lib/widgets/video_controls/widgets/volume_control.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; + +import '../../../services/settings_service.dart'; +import '../../../i18n/strings.g.dart'; + +/// A volume control widget that displays a mute/unmute button and volume slider. +/// +/// This widget integrates with [Player] to control volume and persists +/// the volume setting using [SettingsService]. +class VolumeControl extends StatelessWidget { + final Player player; + + const VolumeControl({super.key, required this.player}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: player.stream.volume, + initialData: player.state.volume, + builder: (context, snapshot) { + final volume = snapshot.data ?? 100.0; + final isMuted = volume == 0; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: isMuted + ? t.videoControls.unmuteButton + : t.videoControls.muteButton, + button: true, + excludeSemantics: true, + child: IconButton( + icon: Icon( + isMuted ? Icons.volume_off : Icons.volume_up, + color: Colors.white, + ), + onPressed: () async { + final newVolume = isMuted ? 100.0 : 0.0; + player.setVolume(newVolume); + final settings = await SettingsService.getInstance(); + await settings.setVolume(newVolume); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 100, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: Semantics( + label: t.videoControls.volumeSlider, + slider: true, + child: Slider( + value: volume, + min: 0.0, + max: 100.0, + onChanged: (value) { + player.setVolume(value); + }, + onChangeEnd: (value) async { + final settings = await SettingsService.getInstance(); + await settings.setVolume(value); + }, + activeColor: Colors.white, + inactiveColor: Colors.white.withValues(alpha: 0.3), + ), + ), + ), + ), + ], + ); + }, + ); + } +}