import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../../core/contracts/cancellation.dart'; import '../../../core/contracts/playback.dart'; import '../../../core/infra/emby_api/playback_resolver.dart'; import '../../../core/infra/player_engine/player_engine.dart'; import '../../../core/media/external_subtitle_loader.dart'; import '../../../providers/audio_settings_provider.dart'; import '../../../providers/player_settings_provider.dart'; import '../../../providers/subtitle_settings_provider.dart'; import '../../../shared/utils/app_logger.dart'; import '../../../shared/utils/emby_ticks.dart'; import '../services/danmaku/danmaku_feeder.dart'; import '../services/emby_playback_reporter.dart'; import '../services/network_speed_monitor.dart'; import '../services/trakt_scrobble_reporter.dart'; import 'initial_subtitle_resolver.dart'; import 'playback_phase.dart'; import 'playback_request.dart'; import 'stall_detector.dart'; const _tag = 'PlaybackSession'; const _openTimeout = Duration(seconds: 60); const _engineErrorGrace = Duration(seconds: 5); const _stallPollInterval = Duration(seconds: 2); const embeddedSubtitleFetchTimeout = Duration(seconds: 8); class PlaybackSession { PlaybackSession({ required this.engine, required this._reporter, required this.speedMonitor, required this.feeder, required this.context, required this.request, required this._prepareResult, required this._streamHeaders, required this._playerSettingsNotifier, this.pendingEngineOpenFuture, TraktScrobbleReporter Function(double Function() readProgress)? traktReporterFactory, this.rememberedAudioTrack, this.rememberedPrimarySubtitle, ExternalSubtitleLoader? externalSubtitleLoader, }) : _externalSubtitleLoader = externalSubtitleLoader ?? ExternalSubtitleLoader() { _subtitleDelaySeconds = request.subtitleSettings.delay; _traktReporter = traktReporterFactory?.call(_progressPercent); _subscriptions = >[ engine.stream.position.listen(_onPosition), engine.stream.buffer.listen(_onBufferChanged), engine.stream.playing.listen(_onPlayingChanged), engine.stream.completed.listen(_onCompleted), engine.stream.volume.listen(_onVolume), engine.stream.tracks.listen(_onTracksChanged), engine.stream.error.listen(_onEngineError), ]; } TraktScrobbleReporter? _traktReporter; final PlayerEngine engine; final EmbyPlaybackReporter _reporter; final NetworkSpeedMonitor speedMonitor; final DanmakuFeeder feeder; final PlaybackContext context; final PlaybackRequest request; final PlaybackRequestResult _prepareResult; final Map? _streamHeaders; final Future? pendingEngineOpenFuture; final PlayerSettingsNotifier _playerSettingsNotifier; final RememberedAudioTrack? rememberedAudioTrack; final RememberedSubtitleTrack? rememberedPrimarySubtitle; final ValueNotifier _phase = ValueNotifier( PlaybackPhase.preparing, ); final CancellationToken _cancel = CancellationToken(); late final List> _subscriptions; bool _disposed = false; bool _started = false; bool _isPaused = false; bool _lastFailureTerminal = false; bool _defaultsApplied = false; bool _engineOpenCompleted = false; Object? _lastError; Timer? _volumeSaveTimer; Timer? _engineErrorTimer; Timer? _stallPollTimer; Duration? _engineErrorAnchorPosition; String? _pendingEngineError; ValueListenable get phase => _phase; CancellationToken get cancelToken => _cancel; Object? get lastError => _lastError; bool get lastFailureTerminal => _lastFailureTerminal; bool get isPaused => _isPaused; bool get isDanmakuActive => _danmakuActive; bool Function()? onStreamFailure; final ExternalSubtitleLoader _externalSubtitleLoader; final ValueNotifier> externalSubtitleLines = ValueNotifier>(const []); final ValueNotifier activeExternalSubtitle = ValueNotifier(null); final ValueNotifier activeSubtitleStreamIndex = ValueNotifier( null, ); final ValueNotifier subtitleLoading = ValueNotifier(false); void Function(String message)? onExternalSubtitleError; SubtitleCueSource? _externalCueSource; double _subtitleDelaySeconds = 0; int _externalLoadGeneration = 0; final Set _prefetchedExternalSubtitleUrls = {}; bool _embeddedExtractionUnavailable = false; final StallDetector _stallDetector = StallDetector(); final Stopwatch _clock = Stopwatch()..start(); bool _failureSignalled = false; Timer? _danmakuInterp; Duration _danmakuAnchorPos = Duration.zero; Duration _danmakuAnchorClock = Duration.zero; Duration? _lastDanmakuPos; bool _danmakuAwaitingMovementBaseline = true; bool _danmakuMoving = false; bool _danmakuActive = false; static const Duration _danmakuInterpInterval = Duration(milliseconds: 32); static const Duration _danmakuStallGrace = Duration(milliseconds: 600); Duration _lastPositionAdvanceClock = Duration.zero; Future start() async { if (_disposed) { throw StateError('PlaybackSession already disposed'); } if (_started) { AppLogger.warn(_tag, 'start() called twice; ignoring'); return; } _started = true; try { _cancel.throwIfCancelled(); speedMonitor.stop(); final startSeconds = _prepareResult.startPositionSeconds; if (startSeconds > 0) { feeder.seekTo(startSeconds, clear: true); } if (pendingEngineOpenFuture != null) { speedMonitor.start(); AppLogger.debug( _tag, 'awaiting pending engine.open (started in build)', ); try { await _runWithTimeout(pendingEngineOpenFuture!, _openTimeout); } catch (_) { speedMonitor.stop(); try { await engine.stop(); } catch (e) { AppLogger.warn(_tag, 'engine.stop after open failure failed', e); } rethrow; } _cancel.throwIfCancelled(); } else { speedMonitor.start(); AppLogger.debug(_tag, 'engine.open already completed before start'); } _engineOpenCompleted = true; AppLogger.debug(_tag, 'engine.open done'); _applySubtitleStyleFromRequest(); _tryApplyDefaultTracks(engine.state.tracks); _cancel.throwIfCancelled(); await engine.play(); _isPaused = false; _setPhase(PlaybackPhase.playing); _startDanmakuInterpolator(); _startStallPolling(); await _reporter.reportStarted(_buildReportPayload(isPaused: false)); _reporter.startProgressTimer(_buildReportPayload); unawaited(_traktReporter?.onPlay() ?? Future.value()); } catch (e, s) { _lastError = e; AppLogger.error(_tag, 'session start failed', e, s); _setPhase(PlaybackPhase.failed); rethrow; } } Future dispose() async { if (_disposed) return; _disposed = true; _cancel.cancel('session disposed'); final sw = Stopwatch()..start(); AppLogger.debug(_tag, 'dispose start'); _volumeSaveTimer?.cancel(); _volumeSaveTimer = null; _engineErrorTimer?.cancel(); _engineErrorTimer = null; _stallPollTimer?.cancel(); _stallPollTimer = null; _danmakuInterp?.cancel(); _danmakuInterp = null; speedMonitor.stop(); final stopReport = _reporter.reportStopped( _buildReportPayload(isPaused: _isPaused), ); unawaited(_traktReporter?.onStop() ?? Future.value()); for (final sub in _subscriptions) { try { await sub.cancel(); } catch (e) { AppLogger.warn(_tag, 'subscription cancel failed', e); } } AppLogger.debug(_tag, 'subs cancelled @${sw.elapsedMilliseconds}ms'); final stopSw = Stopwatch()..start(); try { await engine.stop(); } catch (e) { AppLogger.warn(_tag, 'engine.stop failed', e); } AppLogger.debug( _tag, 'engine.stop done in ${stopSw.elapsedMilliseconds}ms @${sw.elapsedMilliseconds}ms', ); feeder.detach(); _reporter.dispose(); speedMonitor.dispose(); AppLogger.debug(_tag, 'children disposed @${sw.elapsedMilliseconds}ms'); final awaitSw = Stopwatch()..start(); try { await stopReport.timeout(const Duration(seconds: 5)); } catch (e) { AppLogger.debug( _tag, 'stop report timed out / failed after ${awaitSw.elapsedMilliseconds}ms ' '(dispose total ${sw.elapsedMilliseconds}ms)', e, ); } _setPhase(PlaybackPhase.disposed); _phase.dispose(); _externalSubtitleLoader.clear(); externalSubtitleLines.dispose(); activeExternalSubtitle.dispose(); activeSubtitleStreamIndex.dispose(); subtitleLoading.dispose(); AppLogger.debug(_tag, 'dispose done in ${sw.elapsedMilliseconds}ms'); } Future play() async { if (_disposed) return; await engine.play(); } Future pause() async { if (_disposed) return; await engine.pause(); _danmakuAwaitingMovementBaseline = true; _danmakuMoving = false; _syncDanmakuActive(); } Future seek(Duration position) async { if (_disposed) return; _danmakuAwaitingMovementBaseline = true; _danmakuMoving = false; _danmakuActive = false; feeder.seekTo(position.inMilliseconds / 1000.0, clear: true); await engine.seek(position); } Future setRate(double rate) async { if (_disposed) return; await engine.setRate(rate); } Future setVolume(double volume) async { if (_disposed) return; await engine.setVolume(volume); } Future setAudioTrack(AudioTrack track) async { if (_disposed) return; await engine.setAudioTrack(track); } void prefetchExternalSubtitles() { if (_disposed) return; for (final track in _prepareResult.subtitles) { final url = track.deliveryUrl; if (!track.isExternal || url == null || url.isEmpty || !PlaybackResolver.isTextSubtitleCodec(track.codec)) { continue; } if (!_prefetchedExternalSubtitleUrls.add(url)) continue; unawaited(_prefetchExternalSubtitle(url, track.index)); } } Future _prefetchExternalSubtitle(String url, int streamIndex) async { try { await _externalSubtitleLoader.load(url, headers: _streamHeaders); if (!_disposed) { AppLogger.debug( _tag, 'external subtitle prefetched index=$streamIndex', ); } } catch (error) { AppLogger.debug( _tag, 'external subtitle prefetch failed index=$streamIndex', error, ); } } Future selectSubtitleByStreamIndex(int? streamIndex) async { if (_disposed) return; if (streamIndex == null || streamIndex < 0) { AppLogger.info( _tag, 'selectSubtitle: user requested disable (streamIdx=$streamIndex)', ); await _disableSubtitle(); return; } final embyTrack = _prepareResult.subtitles .where((track) => track.index == streamIndex) .firstOrNull; if (embyTrack == null) { final availableIndices = _prepareResult.subtitles .map((track) => track.index) .toList(growable: false); AppLogger.warn( _tag, 'selectSubtitle: streamIdx=$streamIndex not found in emby subtitles ' '(available=$availableIndices)', ); return; } AppLogger.info( _tag, 'selectSubtitle: user requested streamIdx=$streamIndex ' 'external=${embyTrack.isExternal} codec=${embyTrack.codec} ' 'lang=${embyTrack.language} title="${embyTrack.title}"', ); await _applyEmbySubtitleTrack(embyTrack); } Future _disableSubtitle() async { _clearDartSubtitleState(); activeSubtitleStreamIndex.value = null; engine.setNativeSubtitleRendering(false); await engine.setSubtitleTrack(const SubtitleTrack.no()); AppLogger.debug(_tag, 'subtitle disabled'); } Future _applyEmbySubtitleTrack(EmbySubtitleTrack track) async { final url = track.deliveryUrl; final isText = PlaybackResolver.isTextSubtitleCodec(track.codec); final hasUrl = url != null && url.isNotEmpty; final skipExtraction = !track.isExternal && _embeddedExtractionUnavailable; if (hasUrl && isText && !skipExtraction) { AppLogger.info( _tag, 'applySubtitle: route=dart streamIdx=${track.index} ' 'external=${track.isExternal} codec=${track.codec} ' '(text codec with delivery url)', ); return _loadDartSubtitle(track); } final routeReason = !hasUrl ? 'no delivery url' : !isText ? 'bitmap codec' : 'embedded extraction unavailable (this session)'; AppLogger.info( _tag, 'applySubtitle: route=engine streamIdx=${track.index} ' 'external=${track.isExternal} codec=${track.codec} ' 'reason="$routeReason"', ); return _applySubtitleViaEngine(track); } Future _loadDartSubtitle(EmbySubtitleTrack track) async { final url = track.deliveryUrl; if (url == null || url.isEmpty) return false; final generation = ++_externalLoadGeneration; subtitleLoading.value = true; activeExternalSubtitle.value = track; activeSubtitleStreamIndex.value = track.index; _externalCueSource = null; _setExternalSubtitleLines(const []); engine.setNativeSubtitleRendering(false); await engine.setSubtitleTrack(const SubtitleTrack.no()); if (_disposed || generation != _externalLoadGeneration) return true; try { final cues = await _externalSubtitleLoader.load( url, headers: _streamHeaders, receiveTimeout: track.isExternal ? null : embeddedSubtitleFetchTimeout, ); if (_disposed || generation != _externalLoadGeneration) return true; _externalCueSource = SubtitleCueSource(cues); _updateExternalSubtitleLines(engine.state.position); subtitleLoading.value = false; AppLogger.debug( _tag, 'dart subtitle active index=${track.index} ' 'external=${track.isExternal} cues=${cues.length}', ); return true; } catch (e) { if (_disposed || generation != _externalLoadGeneration) return true; AppLogger.warn(_tag, 'dart subtitle load failed idx=${track.index}', e); _clearDartSubtitleState(); if (!track.isExternal) { _embeddedExtractionUnavailable = true; if (_applySubtitleViaEngine(track)) { AppLogger.info( _tag, 'embedded extraction unavailable; fell back to engine switch ' 'idx=${track.index} (subsequent embedded subs skip extraction)', ); return true; } } activeSubtitleStreamIndex.value = null; engine.setNativeSubtitleRendering(false); onExternalSubtitleError?.call('字幕加载失败,已回退为无字幕'); return false; } } bool _applySubtitleViaEngine(EmbySubtitleTrack track) { final engineSubs = engine.state.tracks.embeddedSubtitles; final embeddedIndices = _embeddedEmbySubtitles .map((embyTrack) => embyTrack.index) .toList(growable: false); final pos = embeddedIndices.indexOf(track.index); if (pos < 0 || pos >= engineSubs.length) { AppLogger.warn( _tag, 'applySubtitleViaEngine: streamIdx=${track.index} not found in engine ' 'tracks (embeddedIndices=$embeddedIndices, ' 'engineSubCount=${engineSubs.length})', ); return false; } _clearDartSubtitleState(); activeSubtitleStreamIndex.value = track.index; _configureSubtitleRenderingModeForCodec(track.codec); engine.setSubtitleTrack(engineSubs[pos]); AppLogger.debug( _tag, 'engine subtitle activated streamIdx=${track.index} pos=$pos ' 'engineTrack=${engineSubs[pos].id} codec=${track.codec}', ); return true; } void _clearDartSubtitleState() { _externalLoadGeneration++; subtitleLoading.value = false; activeExternalSubtitle.value = null; _externalCueSource = null; _setExternalSubtitleLines(const []); } void _updateExternalSubtitleLines(Duration position) { final source = _externalCueSource; if (source == null) return; final effectivePosition = position - Duration(milliseconds: (_subtitleDelaySeconds * 1000).round()); final lines = effectivePosition.isNegative ? const [] : source.textAt(effectivePosition); _setExternalSubtitleLines(lines); } void _setExternalSubtitleLines(List lines) { if (listEquals(externalSubtitleLines.value, lines)) return; externalSubtitleLines.value = lines; } void applySubtitleStyle(SubtitleStyleSettings settings) { if (_disposed) return; _subtitleDelaySeconds = settings.delay; _updateExternalSubtitleLines(engine.state.position); engine.setSubtitleStyle( fontSize: settings.fontSize, bgOpacity: settings.bgOpacity, position: settings.position, ); } void _onPosition(Duration position) { if (_disposed) return; _updateExternalSubtitleLines(position); if (_engineErrorAnchorPosition != null && position != _engineErrorAnchorPosition) { _engineErrorTimer?.cancel(); _engineErrorTimer = null; _engineErrorAnchorPosition = null; _pendingEngineError = null; } final last = _lastDanmakuPos; _lastDanmakuPos = position; final positionAdvanced = last != null && position != last; if (positionAdvanced) { _lastPositionAdvanceClock = _clock.elapsed; _danmakuAnchorPos = position; _danmakuAnchorClock = _clock.elapsed; } _checkStall(position); if (_danmakuAwaitingMovementBaseline) { _danmakuAwaitingMovementBaseline = false; _danmakuMoving = false; _lastPositionAdvanceClock = _clock.elapsed; _syncDanmakuActive(); return; } final withinStallGrace = _clock.elapsed - _lastPositionAdvanceClock <= _danmakuStallGrace; _danmakuMoving = positionAdvanced || (_danmakuMoving && withinStallGrace); _syncDanmakuActive(); if (_danmakuActive) { feeder.onPosition(position.inMilliseconds / 1000.0); } } void _onBufferChanged(Duration buffer) { if (_disposed) return; if (!engine.state.playing || buffer > engine.state.position) return; final positionStalled = _clock.elapsed - _lastPositionAdvanceClock > _danmakuStallGrace; if (!positionStalled) return; _danmakuAwaitingMovementBaseline = true; _danmakuMoving = false; _syncDanmakuActive(); } void _syncDanmakuActive() { final active = engine.state.playing && _danmakuMoving; final posSec = engine.state.position.inMilliseconds / 1000.0; if (active && !_danmakuActive) { _danmakuAnchorPos = engine.state.position; _danmakuAnchorClock = _clock.elapsed; feeder.playFrom(posSec); } else if (!active) { feeder.freezeAt(posSec); } _danmakuActive = active; } void _startDanmakuInterpolator() { _danmakuAnchorPos = engine.state.position; _danmakuAnchorClock = _clock.elapsed; _danmakuInterp ??= Timer.periodic(_danmakuInterpInterval, (_) { if (_disposed) return; if (!_danmakuActive) return; final elapsed = _clock.elapsed - _danmakuAnchorClock; if (elapsed.isNegative) return; final rate = engine.state.rate; final est = _danmakuAnchorPos + Duration(microseconds: (elapsed.inMicroseconds * rate).round()); feeder.onPosition(est.inMilliseconds / 1000.0); }); } void _startStallPolling() { _stallPollTimer ??= Timer.periodic(_stallPollInterval, (_) { if (_disposed) return; _checkStall(engine.state.position); }); } void _checkStall(Duration position) { if (_failureSignalled) return; final activelyPlaying = _phase.value == PlaybackPhase.playing && engine.state.playing; final fired = _stallDetector.update( position: position, bufferedPosition: engine.state.buffer, bytesDownloaded: speedMonitor.latestTotalBytes, isActivelyPlaying: activelyPlaying, now: _clock.elapsed, ); if (fired) { AppLogger.warn( _tag, 'stream failure: stall detector (${_stallDetector.threshold.inSeconds}s) → relink', ); _signalFailure(); } } void _signalFailure() { if (_disposed || _failureSignalled) return; final handled = onStreamFailure?.call() ?? false; if (handled) _failureSignalled = true; } void _onEngineError(String error) { if (_disposed || _phase.value == PlaybackPhase.failed) return; AppLogger.warn(_tag, 'engine error: $error'); if (engine.isNetworkStreamError(error)) { _lastFailureTerminal = isTerminalStreamOpenError(error); AppLogger.warn( _tag, 'stream failure: engine network error → ' '${_lastFailureTerminal ? 'terminal (skip relink)' : 'relink'}', ); _signalFailure(); return; } _pendingEngineError = error; _engineErrorAnchorPosition ??= engine.state.position; _engineErrorTimer?.cancel(); _engineErrorTimer = Timer(_engineErrorGrace, () { if (_disposed || _phase.value == PlaybackPhase.failed) return; final anchor = _engineErrorAnchorPosition; if (anchor != null && engine.state.position == anchor) { final message = _pendingEngineError ?? error; _lastError = Exception('播放失败: $message'); _setPhase(PlaybackPhase.failed); } _engineErrorAnchorPosition = null; _pendingEngineError = null; _engineErrorTimer = null; }); } void _onPlayingChanged(bool playing) { if (_disposed) return; if (playing) { _danmakuAwaitingMovementBaseline = true; _danmakuMoving = false; } else { _danmakuAwaitingMovementBaseline = true; _danmakuMoving = false; } _syncDanmakuActive(); final wasPaused = _isPaused; _isPaused = !playing; if (_reporter.startedReported && !_reporter.stoppedReported && _isPaused != wasPaused) { unawaited( _reporter.reportProgress(_buildReportPayload(isPaused: _isPaused)), ); if (_isPaused) { unawaited(_traktReporter?.onPause() ?? Future.value()); } else { unawaited(_traktReporter?.onPlay() ?? Future.value()); } } } void _onCompleted(bool completed) { if (_disposed) return; if (!completed) return; unawaited( _reporter.reportStopped(_buildReportPayload(isPaused: _isPaused)), ); unawaited(_traktReporter?.onCompleted() ?? Future.value()); _setPhase(PlaybackPhase.ended); } void _onVolume(double volume) { if (_disposed) return; _volumeSaveTimer?.cancel(); _volumeSaveTimer = Timer(const Duration(milliseconds: 500), () { if (!_disposed) _playerSettingsNotifier.setVolume(volume); }); } void _onTracksChanged(PlayerTracks tracks) { if (_disposed || _defaultsApplied || !_engineOpenCompleted) return; _tryApplyDefaultTracks(tracks); } void _tryApplyDefaultTracks(PlayerTracks tracks) { if (_defaultsApplied || _disposed) return; final audio = tracks.audio .where((t) => t.id != 'auto' && t.id != 'no') .toList(growable: false); final subs = tracks.subtitle .where((t) => t.id != 'auto' && t.id != 'no') .toList(growable: false); if (audio.isEmpty && subs.isEmpty) return; _defaultsApplied = true; _applyDefaultTracks(audio); } void _applyDefaultTracks(List audioTracks) { if (audioTracks.isNotEmpty) { final audioIndices = _prepareResult.audioTracks .map((e) => e.index) .toList(growable: false); final selectedIndex = _resolveAudioTrackIndex(audioIndices); if (selectedIndex != null) { var target = audioTracks .where((t) => t.index == selectedIndex) .firstOrNull; if (target == null) { final pos = audioIndices.indexOf(selectedIndex); if (pos >= 0 && pos < audioTracks.length) { target = audioTracks[pos]; } } if (target != null) { engine.setAudioTrack(target); } } } final subtitleSelection = resolveInitialSubtitleSelection( subtitles: _prepareResult.subtitles, preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex, defaultSubtitleStreamIndex: _prepareResult.defaultSubtitleIndex, rememberedSubtitle: rememberedPrimarySubtitle, ); final remembered = rememberedPrimarySubtitle; final rememberedSummary = remembered == null ? 'null' : remembered.isNone ? 'none-latch' : 'idx=${remembered.streamIndex} lang=${remembered.language} ' 'title="${remembered.title}"'; AppLogger.info( _tag, 'initialSubtitle: type=${subtitleSelection.type.name} ' 'preferred=${request.preferredSubtitleStreamIndex} ' 'default=${_prepareResult.defaultSubtitleIndex} ' 'remembered=$rememberedSummary ' 'availableCount=${_prepareResult.subtitles.length}', ); switch (subtitleSelection.type) { case InitialSubtitleSelectionType.unchanged: break; case InitialSubtitleSelectionType.disabled: unawaited(_disableSubtitle()); break; case InitialSubtitleSelectionType.track: final selectedTrack = subtitleSelection.track; if (selectedTrack != null) { AppLogger.info( _tag, 'initialSubtitle: applying streamIdx=${selectedTrack.index} ' 'external=${selectedTrack.isExternal} codec=${selectedTrack.codec}', ); unawaited(_applyEmbySubtitleTrack(selectedTrack)); } break; } } List get _embeddedEmbySubtitles => _prepareResult.subtitles .where((track) => !track.isExternal) .toList(growable: false); void _configureSubtitleRenderingModeForCodec(String? codec) { final isBitmap = codec != null && !PlaybackResolver.isTextSubtitleCodec(codec); engine.setNativeSubtitleRendering(isBitmap); } int? _resolveAudioTrackIndex(List audioIndices) { final embyTracks = _prepareResult.audioTracks; final routeIndex = request.preferredAudioStreamIndex; if (routeIndex != null && audioIndices.contains(routeIndex)) { return routeIndex; } final remembered = rememberedAudioTrack; if (remembered != null) { final rememberedIsCommentary = _isCommentary(remembered.displayTitle); final exact = embyTracks .where( (t) => t.index == remembered.streamIndex && _langMatch(t.language, remembered.language) && t.codec == remembered.codec, ) .firstOrNull; if (exact != null) return exact.index; final candidates = rememberedIsCommentary ? embyTracks : embyTracks.where((t) => !_isCommentary(t.displayTitle)).toList(); if (remembered.language != null) { final byLangCodecCh = candidates .where( (t) => _langMatch(t.language, remembered.language) && t.codec == remembered.codec && t.channels == remembered.channels, ) .firstOrNull; if (byLangCodecCh != null) return byLangCodecCh.index; if (remembered.channels != null) { final byLangCh = candidates .where( (t) => _langMatch(t.language, remembered.language) && t.channels == remembered.channels, ) .firstOrNull; if (byLangCh != null) return byLangCh.index; } final byLang = candidates .where((t) => _langMatch(t.language, remembered.language)) .firstOrNull; if (byLang != null) return byLang.index; } } final prefLang = request.audioSettings.preferredAudioLanguage; if (prefLang.isNotEmpty) { var langMatches = embyTracks .where( (t) => _langMatch(t.language, prefLang) && !_isCommentary(t.displayTitle), ) .toList(); if (langMatches.isEmpty) { langMatches = embyTracks .where((t) => _langMatch(t.language, prefLang)) .toList(); } if (langMatches.isNotEmpty) { langMatches.sort((a, b) { final defaultCmp = (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0); if (defaultCmp != 0) return defaultCmp; return (b.channels ?? 0).compareTo(a.channels ?? 0); }); return langMatches.first.index; } } return _prepareResult.mediaSource?.DefaultAudioStreamIndex; } static bool _isCommentary(String? title) { if (title == null) return false; final lower = title.toLowerCase(); return lower.contains('commentary') || lower.contains('comment'); } static bool _langMatch(String? trackLang, String? targetLang) { if (trackLang == null || targetLang == null) return false; final a = trackLang.toLowerCase(); final b = targetLang.toLowerCase(); if (a == b) return true; const subGroups = >{ 'cmn': {'mandarin', '国语', '普通话'}, 'yue': {'cantonese', '粤语'}, }; for (final entry in subGroups.entries) { final all = {entry.key, ...entry.value}; if (all.contains(a) && all.contains(b)) return true; } const macroGroups = >{ 'chi': { 'zho', 'chinese', 'zh', '中文', 'cmn', 'mandarin', '国语', '普通话', 'yue', 'cantonese', '粤语', }, 'eng': {'english', 'en'}, 'jpn': {'japanese', 'ja', 'jp'}, 'kor': {'korean', 'ko'}, 'fra': {'french', 'fr', 'fre'}, 'deu': {'german', 'de', 'ger'}, 'spa': {'spanish', 'es'}, 'ita': {'italian', 'it'}, 'por': {'portuguese', 'pt'}, 'rus': {'russian', 'ru'}, 'tha': {'thai', 'th'}, 'vie': {'vietnamese', 'vi'}, }; for (final entry in macroGroups.entries) { final all = {entry.key, ...entry.value}; if (all.contains(a) && all.contains(b)) return true; } return false; } void _applySubtitleStyleFromRequest() { final s = request.subtitleSettings; engine.setSubtitleStyle( fontSize: s.fontSize, bgOpacity: s.bgOpacity, position: s.position, ); } double _progressPercent() { final pos = engine.state.position; final ticks = context.runTimeTicks; final dur = (ticks != null && ticks > 0) ? durationFromTicks(ticks) : engine.state.duration; if (dur <= Duration.zero || pos <= Duration.zero) return 0; final pct = pos.inMilliseconds / dur.inMilliseconds * 100; return pct.clamp(0.0, 100.0); } PlaybackReportPayload _buildReportPayload({ required bool isPaused, String? eventName, }) { final pos = engine.state.position; final dur = engine.state.duration; return PlaybackReportPayload( itemId: context.itemId, mediaSourceId: context.mediaSourceId, playSessionId: context.playSessionId, positionTicks: ticksFromDuration(pos), runTimeTicks: context.runTimeTicks ?? (dur > Duration.zero ? ticksFromDuration(dur) : null), playMethod: context.playMethod, isPaused: isPaused, isMuted: engine.state.volume <= 0, playbackRate: engine.state.rate, eventName: eventName, ); } void _setPhase(PlaybackPhase next) { if (_phase.value == next) return; if (_phase.value == PlaybackPhase.disposed) return; _phase.value = next; } Future _runWithTimeout(Future future, Duration timeout) { return future.timeout( timeout, onTimeout: () { throw TimeoutException('engine.open exceeded $timeout', timeout); }, ); } }