Files
2026-07-14 11:11:36 +08:00

1054 lines
32 KiB
Dart

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 = <StreamSubscription<dynamic>>[
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<String, String>? _streamHeaders;
final Future<void>? pendingEngineOpenFuture;
final PlayerSettingsNotifier _playerSettingsNotifier;
final RememberedAudioTrack? rememberedAudioTrack;
final RememberedSubtitleTrack? rememberedPrimarySubtitle;
final ValueNotifier<PlaybackPhase> _phase = ValueNotifier<PlaybackPhase>(
PlaybackPhase.preparing,
);
final CancellationToken _cancel = CancellationToken();
late final List<StreamSubscription<dynamic>> _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<PlaybackPhase> 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<List<String>> externalSubtitleLines =
ValueNotifier<List<String>>(const []);
final ValueNotifier<EmbySubtitleTrack?> activeExternalSubtitle =
ValueNotifier<EmbySubtitleTrack?>(null);
final ValueNotifier<int?> activeSubtitleStreamIndex = ValueNotifier<int?>(
null,
);
final ValueNotifier<bool> subtitleLoading = ValueNotifier<bool>(false);
void Function(String message)? onExternalSubtitleError;
SubtitleCueSource? _externalCueSource;
double _subtitleDelaySeconds = 0;
int _externalLoadGeneration = 0;
final Set<String> _prefetchedExternalSubtitleUrls = <String>{};
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<void> 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<void>.value());
} catch (e, s) {
_lastError = e;
AppLogger.error(_tag, 'session start failed', e, s);
_setPhase(PlaybackPhase.failed);
rethrow;
}
}
Future<void> 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<void>.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<void> play() async {
if (_disposed) return;
await engine.play();
}
Future<void> pause() async {
if (_disposed) return;
await engine.pause();
_danmakuAwaitingMovementBaseline = true;
_danmakuMoving = false;
_syncDanmakuActive();
}
Future<void> 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<void> setRate(double rate) async {
if (_disposed) return;
await engine.setRate(rate);
}
Future<void> setVolume(double volume) async {
if (_disposed) return;
await engine.setVolume(volume);
}
Future<void> 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<void> _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<void> 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<void> _disableSubtitle() async {
_clearDartSubtitleState();
activeSubtitleStreamIndex.value = null;
engine.setNativeSubtitleRendering(false);
await engine.setSubtitleTrack(const SubtitleTrack.no());
AppLogger.debug(_tag, 'subtitle disabled');
}
Future<bool> _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<bool> _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 <String>[]
: source.textAt(effectivePosition);
_setExternalSubtitleLines(lines);
}
void _setExternalSubtitleLines(List<String> 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<void>.value());
} else {
unawaited(_traktReporter?.onPlay() ?? Future<void>.value());
}
}
}
void _onCompleted(bool completed) {
if (_disposed) return;
if (!completed) return;
unawaited(
_reporter.reportStopped(_buildReportPayload(isPaused: _isPaused)),
);
unawaited(_traktReporter?.onCompleted() ?? Future<void>.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<AudioTrack> 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<EmbySubtitleTrack> 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<int> 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 = <String, Set<String>>{
'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 = <String, Set<String>>{
'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<void> _runWithTimeout(Future<void> future, Duration timeout) {
return future.timeout(
timeout,
onTimeout: () {
throw TimeoutException('engine.open exceeded $timeout', timeout);
},
);
}
}