perf: parallelize playback startup

This commit is contained in:
edde746
2026-04-24 23:20:31 +02:00
parent df94e83592
commit 3a69e49ab8
5 changed files with 149 additions and 79 deletions
@@ -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)
}
}
+11 -1
View File
@@ -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<String?>? _cachedFontDir;
/// Loads the subtitle font from assets to the cache directory.
/// Returns the directory path containing the font file.
static Future<String?> loadSubtitleFont() async {
static Future<String?> loadSubtitleFont() {
return _cachedFontDir ??= _loadSubtitleFontOnce();
}
static Future<String?> _loadSubtitleFontOnce() async {
try {
// Get the app's cache directory
final cacheDir = await getTemporaryDirectory();
+106 -59
View File
@@ -183,6 +183,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<PlaybackInitializationResult>? _playbackDataFuture;
Map<String, String>? _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<void>? _audioFocusFuture;
late final String _playbackSessionIdentifier;
late final String _playbackTranscodeSessionId;
StreamSubscription<PlayerError>? _errorSubscription;
@@ -609,6 +620,50 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<SettingsProvider>();
_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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<SettingsProvider>();
_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<VideoPlayerScreen> 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<VideoPlayerScreen> 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',
@@ -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();
+21 -16
View File
@@ -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<void> addExternalSubtitles(List<SubtitleTrack> 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).