fix: download playback issues (doubled path, audio language, spinner)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user