fix: download playback issues (doubled path, audio language, spinner)

This commit is contained in:
edde746
2026-02-27 15:26:49 +01:00
parent 9e0c3e7b68
commit 1cdf1890cf
3 changed files with 100 additions and 25 deletions
+53
View File
@@ -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<String, dynamic> metadata) {
final media = metadata['Media'] as List<dynamic>?;
if (media == null || media.isEmpty) return null;
final parts = media[0]['Part'] as List<dynamic>?;
if (parts == null || parts.isEmpty) return null;
final streams = parts[0]['Stream'] as List<dynamic>?;
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
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.
+38 -18
View File
@@ -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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<bool> _isBuffering = ValueNotifier<bool>(false); // Track if video is currently buffering
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); // Track if first video frame has rendered
@@ -1026,17 +1021,21 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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,
+9 -7
View File
@@ -369,15 +369,17 @@ class DownloadStorageService {
Future<String> 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;