From 3a69e49ab88b5270ddf1cacfbec75afa3e692e0a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:20:31 +0200 Subject: [PATCH] perf: parallelize playback startup --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 6 + lib/mpv/font_loader.dart | 12 +- lib/screens/video_player_screen.dart | 165 +++++++++++------- .../playback_initialization_service.dart | 8 +- lib/services/track_manager.dart | 37 ++-- 5 files changed, 149 insertions(+), 79 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index d2b5b235..89c95fe6 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -1174,6 +1174,12 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { firstFrameRendered = true cancelDecoderHangCheck() emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") + // STATE_READY fires when the player has enough buffered to start, but + // the first frame may not be on screen yet (decoder init + keyframe + // decode). The MPV-parity `playback-restart` event consumers (Dart + // first-frame detection, frame-rate matching) want the moment the + // pixel actually hits the screen, which is here. + delegate?.onEvent("playback-restart", null) } } diff --git a/lib/mpv/font_loader.dart b/lib/mpv/font_loader.dart index 99e9612a..331ef682 100644 --- a/lib/mpv/font_loader.dart +++ b/lib/mpv/font_loader.dart @@ -12,9 +12,19 @@ class SubtitleFontLoader { static const String _fontAssetPath = 'assets/go-noto-current-regular.ttf'; static const String _fontName = 'Go Noto Current-Regular'; + /// In-memory cache of the resolved font directory. The filesystem work + /// (temp dir lookup, existence checks, asset extraction) is idempotent per + /// process — caching the result skips ~20ms on every subsequent Player + /// instantiation. + static Future? _cachedFontDir; + /// Loads the subtitle font from assets to the cache directory. /// Returns the directory path containing the font file. - static Future loadSubtitleFont() async { + static Future loadSubtitleFont() { + return _cachedFontDir ??= _loadSubtitleFontOnce(); + } + + static Future _loadSubtitleFontOnce() async { try { // Get the app's cache directory final cacheDir = await getTemporaryDirectory(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index a889fd14..a01c5b52 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -183,6 +183,17 @@ class VideoPlayerScreenState extends State with WidgetsBindin int? _selectedAudioStreamId; bool _isTranscoding = false; bool _serverSupportsTranscoding = false; + // Kicked off early in `_initializePlayer` for online non-live playback so + // the metadata fetch (and transcode-decision HTTP, if non-original preset) + // overlaps with MPV property configuration. Awaited inside `_startPlayback` + // immediately before `player.open()` needs the video URL. + Future? _playbackDataFuture; + Map? _plexHeaders; + // Fired in parallel with MPV setup so the OS audio-focus negotiation + // (~90ms on Android) doesn't sit on the critical path. Awaited before + // `player.open()` so the semantics are unchanged — we just eat the cost + // during otherwise-idle setup time. + Future? _audioFocusFuture; late final String _playbackSessionIdentifier; late final String _playbackTranscodeSessionId; StreamSubscription? _errorSubscription; @@ -609,6 +620,50 @@ class VideoPlayerScreenState extends State with WidgetsBindin player = Player(useExoPlayer: useExoPlayer); _playerBackendLabel = player!.playerType; + // Kick off audio-focus negotiation in parallel with MPV config + prefetch. + // On Android this is a round-trip to AudioManager (~90ms cold). + if (Platform.isAndroid && !widget.isLive) { + _audioFocusFuture = player!.requestAudioFocus(); + _audioFocusFuture!.ignore(); + } + + // Kick off getPlaybackData() in parallel with the rest of MPV setup. + // The network/DB work has no dependency on the player — it just needs + // the context (providers), which is still safe to touch here because + // no async gaps invalidate it before the calls below read it. + // Skipped for live TV (has its own tune path) and offline (its own + // branch in _startPlayback). + if (!widget.isLive && !widget.isOffline && mounted) { + final client = _getClientForMetadata(context); + _plexHeaders = client.config.headers; + if (widget.selectedQualityPreset == null) { + try { + final settingsProvider = context.read(); + _selectedQualityPreset = settingsProvider.defaultQualityPreset; + } catch (_) { + _selectedQualityPreset = TranscodeQualityPreset.original; + } + } else { + _selectedQualityPreset = widget.selectedQualityPreset!; + } + _serverSupportsTranscoding = client.serverSupportsVideoTranscodingCached; + final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database); + _playbackDataFuture = playbackService.getPlaybackData( + metadata: _currentMetadata, + selectedMediaIndex: widget.selectedMediaIndex, + preferOffline: _selectedQualityPreset.isOriginal, + playbackData: widget.playbackData, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ); + // If MPV setup below throws before `_startPlayback` awaits this, + // tell Dart we've "handled" the future so it's not reported as an + // unhandled async error. The later `await` still receives the error. + _playbackDataFuture!.ignore(); + } + await player!.configureSubtitleFonts(); await player!.setProperty('sub-ass', 'yes'); // Enable libass if (Platform.isAndroid && useExoPlayer) { @@ -864,17 +919,25 @@ class VideoPlayerScreenState extends State with WidgetsBindin }); // Listen to position for completion detection (fallback for unreliable MPV events) + int? lastObservedPositionMs; _positionSubscription = player!.streams.position.listen((position) { - // Fallback for cases where playbackRestart doesn't fire (observed on some - // offline Android playback flows). Prevents a permanent loading spinner. - if (!_hasFirstFrame.value && position.inMilliseconds > 0) { - _hasFirstFrame.value = true; + // Fallback for cases where playbackRestart doesn't fire (observed on + // some offline Android playback flows). Prevents a permanent loading + // spinner. Checking `position > 0` was broken for resume playback — + // the native layer sets position to the resume offset before the first + // frame renders, so the fallback tripped immediately. Requiring a + // position *change* ensures we only fire when playback is advancing. + if (!_hasFirstFrame.value) { + if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) { + _hasFirstFrame.value = true; - // Apply frame rate matching here too, since this fallback may fire - // before playbackRestart (race condition with resume positions > 0) - if (Platform.isAndroid && settingsService.getMatchContentFrameRate()) { - _applyFrameRateMatching(); + // Apply frame rate matching here too, since this fallback may fire + // before playbackRestart (race condition with resume positions > 0) + if (Platform.isAndroid && settingsService.getMatchContentFrameRate()) { + _applyFrameRateMatching(); + } } + lastObservedPositionMs = position.inMilliseconds; } final duration = player!.state.duration; @@ -886,13 +949,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin } }); - // Initialize services + // Services init must finish before `_loadAdjacentEpisodes` so Discord / + // Trakt / Tracker start-playback calls have fired before first-frame. + // Play queue is only consumed by next/previous navigation buttons which + // the user can't hit until after first frame; fire-and-forget removes + // its HTTP latency from the critical path. + unawaited(_ensurePlayQueue()); await _initializeServices(); - // Ensure play queue exists for sequential playback - await _ensurePlayQueue(); - - // Load next/previous episodes + // Load next/previous episodes (fire-and-forget) _loadAdjacentEpisodes(); } catch (e) { appLogger.e('Failed to initialize player', error: e); @@ -1126,11 +1191,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin } }); - // Update media metadata (client can be null in offline mode - artwork won't be shown) - await _mediaControlsManager!.updateMetadata( - metadata: _currentMetadata, - client: client, - duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, + // Update media metadata (client can be null in offline mode - artwork won't + // be shown). Fire-and-forget: the OS media-controls plugin downloads the + // poster synchronously inside `setMetadata` (~270ms). The controls populate + // a beat after first frame which is fine; it's not visible during loading. + unawaited( + _mediaControlsManager!.updateMetadata( + metadata: _currentMetadata, + client: client, + duration: _currentMetadata.duration != null ? Duration(milliseconds: _currentMetadata.duration!) : null, + ), ); if (!mounted) return; @@ -1427,44 +1497,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin // Offline mode: get video path from downloads without requiring server result = await _startOfflinePlayback(); } else { - // Online mode: use server-specific client - final client = _getClientForMetadata(context); - plexHeaders = client.config.headers; - - // Resolve quality preset from widget → SettingsProvider default. - if (widget.selectedQualityPreset == null && mounted) { - try { - final settingsProvider = context.read(); - _selectedQualityPreset = settingsProvider.defaultQualityPreset; - } catch (_) { - _selectedQualityPreset = TranscodeQualityPreset.original; - } - } else { - _selectedQualityPreset = widget.selectedQualityPreset ?? TranscodeQualityPreset.original; - } - - // Capability flag comes from the warm cache populated at connection - // time. If the warm-up hasn't landed yet (rare — playback within a - // few seconds of connect), the sync getter returns `true` and the - // transcode decision's own fallback handles a "not actually supported" - // server without blocking the hot path. - _serverSupportsTranscoding = client.serverSupportsVideoTranscodingCached; - - final playbackService = PlaybackInitializationService(client: client, database: PlexApiCache.instance.database); - // Only prefer a downloaded local file when the user wants Original - // quality. Any non-Original preset implies an explicit request to - // transcode, which can only be satisfied by hitting the server — the - // local file is always source quality. - result = await playbackService.getPlaybackData( - metadata: _currentMetadata, - selectedMediaIndex: widget.selectedMediaIndex, - preferOffline: _selectedQualityPreset.isOriginal, - playbackData: widget.playbackData, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, - ); + // Online path: `_playbackDataFuture` was kicked off in `_initializePlayer` + // in parallel with MPV setup. Quality preset + server capabilities + + // headers were resolved there too. Just await the result. + plexHeaders = _plexHeaders; + result = await _playbackDataFuture!; _isTranscoding = result.isTranscoding; if (result.activeAudioStreamId != null) { @@ -1496,8 +1533,15 @@ class VideoPlayerScreenState extends State with WidgetsBindin _frameRateMatchingApplied = false; // Request audio focus before starting playback (Android) - // This causes other media apps (Spotify, podcasts, etc.) to pause - await player!.requestAudioFocus(); + // This causes other media apps (Spotify, podcasts, etc.) to pause. + // Fired in parallel with MPV setup in `_initializePlayer`; we await + // the in-flight future here (usually already resolved). + if (_audioFocusFuture != null) { + await _audioFocusFuture; + _audioFocusFuture = null; + } else { + await player!.requestAudioFocus(); + } // Pass resume position if available. // In offline mode, prefer locally tracked progress over the cached server value @@ -1515,7 +1559,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin ? Duration(milliseconds: _currentMetadata.viewOffset!) : null; - // Enable FFmpeg auto-reconnect for VOD streams (covers network drops up to 10 min) + // Enable FFmpeg auto-reconnect for VOD streams (covers network drops + // up to 10 min). Forwarded to the Kotlin layer on Android so MPV + // inherits it on the ExoPlayer→MPV fallback path (see + // _onBackendSwitched), so keep it unconditional. if (!widget.isOffline && !widget.isLive) { await player!.setProperty( 'stream-lavf-o', diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index f6c1b5f1..7e965e53 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -6,12 +6,12 @@ import '../models/download_models.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/mpv.dart'; import '../utils/app_logger.dart'; +import '../utils/global_key_utils.dart'; import '../utils/plex_url_helper.dart'; import '../i18n/strings.g.dart'; import '../database/app_database.dart'; import 'download_storage_service.dart'; import 'dart:io'; -import 'package:drift/drift.dart'; /// Service responsible for fetching video playback data from the Plex server class PlaybackInitializationService { @@ -35,9 +35,11 @@ class PlaybackInitializationService { } try { - // Query database for downloaded media with matching serverId and ratingKey + // Query by globalKey — the column is UNIQUE so SQLite's auto-index on it + // makes this an O(log n) lookup. Filtering by (serverId, ratingKey) + // would only use the serverId index and then linear-scan matching rows. final query = database!.select(database!.downloadedMedia) - ..where((tbl) => tbl.serverId.equals(serverId) & tbl.ratingKey.equals(ratingKey)); + ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(serverId, ratingKey))); final downloadedItem = await query.getSingleOrNull(); diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index b8ec82f7..c367a9b7 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -77,27 +77,32 @@ class TrackManager { _lastExternalSubtitles = externalSubtitles; } - /// Add external subtitle tracks to the player one by one. + /// Add external subtitle tracks to the player in parallel. + /// + /// Each sub-add does its own HTTP fetch of the sidecar file, so sequential + /// adds dominate startup (~170ms × N). Firing them in parallel lets + /// libavformat's network IO overlap and stops Dart → method channel → native + /// round-trips from stacking. Future addExternalSubtitles(List externalSubtitles) async { if (externalSubtitles.isEmpty) return; appLogger.d('Adding ${externalSubtitles.length} external subtitle(s) to player'); - for (final subtitleTrack in externalSubtitles) { - if (subtitleTrack.uri == null) continue; - - try { - await player.addSubtitleTrack( - uri: subtitleTrack.uri!, - title: subtitleTrack.title, - language: subtitleTrack.language, - select: false, - ); - appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}'); - } catch (e) { - appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e); - } - } + await Future.wait( + externalSubtitles.where((s) => s.uri != null).map((subtitleTrack) async { + try { + await player.addSubtitleTrack( + uri: subtitleTrack.uri!, + title: subtitleTrack.title, + language: subtitleTrack.language, + select: false, + ); + appLogger.d('Added external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}'); + } catch (e) { + appLogger.w('Failed to add external subtitle: ${subtitleTrack.title ?? subtitleTrack.uri}', error: e); + } + }), + ); } /// Resume playback after external subtitles have been loaded (or failed).