1054 lines
32 KiB
Dart
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);
|
|
},
|
|
);
|
|
}
|
|
}
|