diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 5b32221c..ab2bea74 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -15,6 +15,59 @@ class PlexMediaInfo { this.partId, }); int? getPartId() => partId; + + /// Creates a [PlexMediaInfo] from cached metadata JSON (as stored by [PlexApiCache]). + /// Parses audio/subtitle tracks from `Media[0].Part[0].Stream[]` so that + /// offline playback can still apply language-based track selection. + static PlexMediaInfo? fromMetadataJson(Map metadata) { + final media = metadata['Media'] as List?; + if (media == null || media.isEmpty) return null; + final parts = media[0]['Part'] as List?; + if (parts == null || parts.isEmpty) return null; + final streams = parts[0]['Stream'] as List?; + + final audioTracks = []; + final subtitleTracks = []; + + if (streams != null) { + for (final s in streams) { + final streamType = s['streamType'] as int?; + if (streamType == 2) { + audioTracks.add(PlexAudioTrack( + id: s['id'] as int, + index: s['index'] as int?, + codec: s['codec'] as String?, + language: s['language'] as String?, + languageCode: s['languageCode'] as String?, + title: s['title'] as String?, + displayTitle: s['displayTitle'] as String?, + channels: s['channels'] as int?, + selected: s['selected'] == 1 || s['selected'] == true, + )); + } else if (streamType == 3) { + subtitleTracks.add(PlexSubtitleTrack( + id: s['id'] as int, + index: s['index'] as int?, + codec: s['codec'] as String?, + language: s['language'] as String?, + languageCode: s['languageCode'] as String?, + title: s['title'] as String?, + displayTitle: s['displayTitle'] as String?, + selected: s['selected'] == 1 || s['selected'] == true, + forced: s['forced'] == 1, + key: s['key'] as String?, + )); + } + } + } + + return PlexMediaInfo( + videoUrl: '', + audioTracks: audioTracks, + subtitleTracks: subtitleTracks, + chapters: const [], + ); + } } /// Builds a track label from parts with the standard `' · '` joiner pattern. diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index bba46692..db002de2 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -13,6 +13,7 @@ import 'package:window_manager/window_manager.dart'; import '../mpv/mpv.dart'; import '../mpv/player/platform/player_android.dart'; +import '../../services/bif_thumbnail_service.dart'; import '../../services/plex_client.dart'; import '../models/livetv_channel.dart'; import '../services/plex_api_cache.dart'; @@ -20,6 +21,7 @@ import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_video_playback_data.dart'; import '../utils/content_utils.dart'; +import '../utils/plex_cache_parser.dart'; import '../models/plex_media_info.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -139,7 +141,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _isDisposingForNavigation = false; bool _waitingForExternalSubsTrackSelection = false; bool _isHandlingBack = false; - bool _hasThumbnails = false; + BifThumbnailService? _bifService; // Live TV channel navigation int _liveChannelIndex = -1; @@ -198,14 +200,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin return context.getClientForServer(widget.metadata.serverId!); } - String? _buildThumbnailUrl(BuildContext context, Duration time) { - final partId = _currentMediaInfo?.partId; - if (partId == null || widget.isOffline) return null; - final client = _getClientForMetadata(context); - return '${client.config.baseUrl}/library/parts/$partId/indexes/sd/${time.inMilliseconds}'.withPlexToken( - client.config.token, - ); - } + Uint8List? _getThumbnailData(Duration time) => _bifService?.getThumbnail(time); final ValueNotifier _isBuffering = ValueNotifier(false); // Track if video is currently buffering final ValueNotifier _hasFirstFrame = ValueNotifier(false); // Track if first video frame has rendered @@ -1026,17 +1021,21 @@ class VideoPlayerScreenState extends State with WidgetsBindin setState(() { _availableVersions = result.availableVersions.cast(); _currentMediaInfo = result.mediaInfo; - _hasThumbnails = false; + _bifService?.dispose(); + _bifService = null; }); - // Check whether any thumbnails exist by requesting the first one + // Download and cache BIF thumbnail file if (_currentMediaInfo?.partId != null && !widget.isOffline) { final partId = _currentMediaInfo!.partId!; final client = _getClientForMetadata(context); - client.checkThumbnailsAvailable(partId).then((available) { - // Guard against media having changed while the probe was in flight + final service = BifThumbnailService(); + service.load(client, partId).then((_) { + // Guard against media having changed while the download was in flight if (mounted && _currentMediaInfo?.partId == partId) { - setState(() => _hasThumbnails = available); + setState(() => _bifService = service); + } else { + service.dispose(); } }); } @@ -1094,10 +1093,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } on PlaybackException catch (e) { if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error showErrorSnackBar(context, e.message); } } catch (e) { if (mounted) { + _hasFirstFrame.value = true; // Hide spinner on error showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } @@ -1142,10 +1143,28 @@ class VideoPlayerScreenState extends State with WidgetsBindin appLogger.d('Starting offline playback: $videoPath'); + // Load cached media info so track selection (audio language) works offline + PlexMediaInfo? mediaInfo; + try { + final serverId = widget.metadata.serverId; + if (serverId != null) { + final cached = await PlexApiCache.instance.get( + serverId, + '/library/metadata/${widget.metadata.ratingKey}', + ); + final metadataJson = PlexCacheParser.extractFirstMetadata(cached); + if (metadataJson != null) { + mediaInfo = PlexMediaInfo.fromMetadataJson(metadataJson); + } + } + } catch (e) { + appLogger.d('Could not load cached media info for offline playback', error: e); + } + return PlaybackInitializationResult( availableVersions: [], videoUrl: videoPath.contains('://') ? videoPath : 'file://$videoPath', - mediaInfo: null, + mediaInfo: mediaInfo, externalSubtitles: const [], isOffline: true, ); @@ -1604,6 +1623,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin _videoPIPManager?.onBeforeEnterPip = null; _videoFilterManager?.dispose(); + // Release cached BIF thumbnail data + _bifService?.dispose(); + // Mark sleep timer for restart if truly exiting (not episode transition) if (!_isReplacingWithVideo) { SleepTimerService().markNeedsRestart(); @@ -2505,9 +2527,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin shaderService: _shaderService, // ignore: no-empty-block - setState triggers rebuild to reflect shader change onShaderChanged: () => setState(() {}), - thumbnailUrlBuilder: _hasThumbnails && _currentMediaInfo?.partId != null - ? (Duration time) => _buildThumbnailUrl(context, time)! - : null, + thumbnailDataBuilder: _bifService?.isAvailable == true ? _getThumbnailData : null, isLive: widget.isLive, liveChannelName: _liveChannelName, isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 4ca77b81..7bf3573c 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -369,15 +369,17 @@ class DownloadStorageService { Future toRelativePath(String absolutePath) async { final baseDir = await _getBaseAppDir(); - // If the path starts with the base directory, strip it - if (absolutePath.startsWith(baseDir.path)) { - // Remove the base path and any leading separator - var relative = absolutePath.substring(baseDir.path.length); - if (relative.startsWith('/') || relative.startsWith('\\')) { - relative = relative.substring(1); + // Strip the base directory prefix iteratively — background_downloader + // recovery paths can contain the base dir doubled (e.g. + // /data/.../app_flutter/data/.../app_flutter/downloads/...). + var result = absolutePath; + while (result.startsWith(baseDir.path)) { + result = result.substring(baseDir.path.length); + if (result.startsWith('/') || result.startsWith('\\')) { + result = result.substring(1); } - return relative; } + if (result != absolutePath) return result; // Already relative or from a different base - return as-is return absolutePath;