From 60a84380a63292a2ef62123b34364af6ac3a2c69 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 6 Nov 2025 10:36:26 +0100 Subject: [PATCH] feat: external subtitle support --- lib/models/plex_media_info.dart | 44 ++++++++ lib/screens/video_player_screen.dart | 105 ++++++++++++++++-- .../video_controls/video_control_button.dart | 2 +- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index ad8c7387..b44cdb37 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -77,6 +77,50 @@ class PlexSubtitleTrack { if (forced) parts.add('Forced'); return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' ยท '); } + + /// Returns true if this subtitle track is an external file (sidecar subtitle) + /// External subtitles have a key property that points to /library/streams/{id} + bool get isExternal => key != null && key!.isNotEmpty; + + /// Constructs the full URL for fetching external subtitle files + /// Returns null if this is not an external subtitle + String? getSubtitleUrl(String baseUrl, String token) { + if (!isExternal) return null; + + // Determine file extension based on codec + final ext = _getExtensionFromCodec(codec); + + // Construct URL with authentication token + return '$baseUrl$key.$ext?X-Plex-Token=$token'; + } + + /// Maps Plex subtitle codec names to file extensions + String _getExtensionFromCodec(String? codec) { + if (codec == null) return 'srt'; + + switch (codec.toLowerCase()) { + case 'subrip': + case 'srt': + return 'srt'; + case 'ass': + return 'ass'; + case 'ssa': + return 'ssa'; + case 'webvtt': + case 'vtt': + return 'vtt'; + case 'mov_text': + return 'srt'; + case 'pgs': + case 'hdmv_pgs_subtitle': + return 'sup'; + case 'dvd_subtitle': + case 'dvdsub': + return 'sub'; + default: + return 'srt'; // Default to SRT for unknown codecs + } + } } class PlexChapter { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 260505c2..c3627aa0 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -54,7 +54,8 @@ class VideoPlayerScreenState extends State { StreamSubscription? _errorSubscription; StreamSubscription? _playingSubscription; StreamSubscription? _completedSubscription; - bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation + bool _isReplacingWithVideo = + false; // Flag to skip orientation restoration during video-to-video navigation // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode = 0; @@ -123,10 +124,13 @@ class VideoPlayerScreenState extends State { mpvConfiguration: { 'sub-font-size': settingsService.getSubtitleFontSize().toString(), 'sub-color': settingsService.getSubtitleTextColor(), - 'sub-border-size': settingsService.getSubtitleBorderSize().toString(), + 'sub-border-size': settingsService + .getSubtitleBorderSize() + .toString(), 'sub-border-color': settingsService.getSubtitleBorderColor(), - 'sub-back-color': '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', - } + 'sub-back-color': + '#${(settingsService.getSubtitleBackgroundOpacity() * 255 / 100).toInt().toRadixString(16).padLeft(2, '0').toUpperCase()}${settingsService.getSubtitleBackgroundColor().replaceFirst('#', '')}', + }, ), ); controller = VideoController( @@ -194,10 +198,14 @@ class VideoPlayerScreenState extends State { } // Listen to playback state changes - _playingSubscription = player!.stream.playing.listen(_onPlayingStateChanged); + _playingSubscription = player!.stream.playing.listen( + _onPlayingStateChanged, + ); // Listen to completion - _completedSubscription = player!.stream.completed.listen(_onVideoCompleted); + _completedSubscription = player!.stream.completed.listen( + _onVideoCompleted, + ); // Listen to MPV logs _logSubscription = player!.stream.log.listen(_onPlayerLog); @@ -273,7 +281,63 @@ class VideoPlayerScreenState extends State { ); if (videoUrl != null) { - // Open video without auto-playing + // Fetch media info to check for external subtitle tracks + final mediaInfo = await client.getMediaInfo( + widget.metadata.ratingKey, + mediaIndex: widget.selectedMediaIndex, + ); + + // Build list of external subtitle tracks for media_kit + final externalSubtitles = []; + if (mediaInfo != null) { + final externalTracks = mediaInfo.subtitleTracks + .where((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, + ); + } + } + } + + // Open video (without external subtitles in Media constructor) await player!.open(Media(videoUrl), play: false); // Wait for media to be ready (duration > 0) @@ -283,6 +347,33 @@ class VideoPlayerScreenState extends State { 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) { diff --git a/lib/widgets/video_controls/video_control_button.dart b/lib/widgets/video_controls/video_control_button.dart index c87b50be..0b0f6b3a 100644 --- a/lib/widgets/video_controls/video_control_button.dart +++ b/lib/widgets/video_controls/video_control_button.dart @@ -40,7 +40,7 @@ class VideoControlButton extends StatelessWidget { icon: Icon(icon, color: effectiveColor), onPressed: onPressed, tooltip: tooltip, - constraints: const BoxConstraints(minWidth: 40, minHeight: 56), + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), ); } }