Files
sm-emby-share/lib/features/player/session/playback_session_builder.dart
T
2026-07-14 11:11:36 +08:00

617 lines
20 KiB
Dart

import 'dart:async';
import '../../../core/contracts/auth.dart';
import '../../../core/contracts/cancellation.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../core/contracts/library.dart';
import '../../../core/contracts/playback.dart';
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
import '../../../core/domain/ports/authed_trakt_gateway.dart';
import '../../../core/domain/ports/emby_gateway.dart';
import '../../../core/infra/emby_api/emby_headers.dart';
import '../../../core/infra/trakt_api/trakt_media_matcher.dart';
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
import '../../../core/infra/emby_api/playback_resolver.dart';
import '../../../core/infra/player_engine/fvp_player_engine.dart';
import '../../../core/infra/player_engine/media3_player_engine.dart';
import '../../../core/infra/player_engine/media_kit_player_engine.dart';
import '../../../core/infra/player_engine/player_engine.dart';
import '../../../core/media/external_subtitle_loader.dart';
import '../../../core/media/media_capability_analyzer.dart';
import '../../../core/media/media_source_metadata_resolver.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../providers/player_engine_override_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/subtitle_settings_provider.dart';
import '../../../shared/mappers/media_image_url.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_request.dart';
import 'playback_session.dart';
import 'player_engine_holder.dart';
import 'player_engine_resolution.dart';
const _tag = 'PlaybackSessionBuilder';
const _seriesIdentitySoftTimeout = Duration(milliseconds: 800);
const _engineOpenTimeout = Duration(seconds: 60);
class PlaybackSessionBuilder {
PlaybackSessionBuilder({
required this.resolver,
required this.gateway,
required this.metadataResolver,
required this.engineHolder,
required this.readActiveSession,
required this.deviceId,
required this.deviceName,
required this.playerSettingsNotifier,
required this.audioSettingsNotifier,
required this.subtitleSettingsNotifier,
required this.readEngineOverride,
this.loadItemDetail,
this.traktGateway,
this.traktQueue,
this.traktAppVersion,
this.traktOnSuccess,
this.traktOnError,
});
final PlaybackResolver resolver;
final EmbyGateway gateway;
final MediaSourceMetadataResolver metadataResolver;
final PlayerEngineHolder engineHolder;
final AuthedTraktGateway? traktGateway;
final TraktPendingSyncQueue? traktQueue;
final String? traktAppVersion;
final void Function()? traktOnSuccess;
final void Function(String error)? traktOnError;
Future<bool>? _traktConnectedCache;
final AuthedSession Function() readActiveSession;
final PlayerEngineOverride Function() readEngineOverride;
final String deviceId;
final String deviceName;
final PlayerSettingsNotifier playerSettingsNotifier;
final AudioSettingsNotifier audioSettingsNotifier;
final SubtitleSettingsNotifier subtitleSettingsNotifier;
final Future<EmbyRawItem> Function(String serverId, String itemId)?
loadItemDetail;
Future<PlaybackSession> build({
required PlaybackRequest request,
required DanmakuFeeder feeder,
CancellationToken? externalCancel,
Future<void>? priorDisposeFuture,
Map<String, dynamic>? preloadedPlaybackInfo,
PlayerEngineKind? forcedEngineKind,
void Function(PlayerEngineKind kind, bool isDolbyVision)? onEngineResolved,
}) async {
externalCancel?.throwIfCancelled();
final directStreamUrl = request.directStreamUrl?.trim();
final isDirectStream = directStreamUrl?.isNotEmpty ?? false;
final identity = request.targetServer;
final String effectiveServerId;
final String effectiveServerUrl;
final String effectiveToken;
final String effectiveUserId;
if (identity != null) {
effectiveServerId = identity.serverId;
effectiveServerUrl = identity.baseUrl;
effectiveToken = identity.token;
effectiveUserId = identity.userId;
} else if (isDirectStream) {
effectiveServerId = '';
effectiveServerUrl = '';
effectiveToken = '';
effectiveUserId = '';
} else {
final session = readActiveSession();
effectiveServerId = session.serverId;
effectiveServerUrl = session.serverUrl;
effectiveToken = session.token;
effectiveUserId = session.userId;
}
final ctx = AuthedRequestContext(
baseUrl: effectiveServerUrl,
token: effectiveToken,
userId: effectiveUserId,
);
final traktConnectedFuture = isDirectStream
? Future<bool>.value(false)
: _readTraktConnected();
final PlaybackRequestResult result;
if (isDirectStream) {
final item =
request.preloadedItem ??
EmbyRawItem(Id: request.itemId, Name: request.itemId, Type: 'Video');
result = PlaybackRequestResult(
streamUrl: directStreamUrl!,
directStream: true,
item: item,
startPositionSeconds: request.playFromStart
? 0
: (request.startPositionTicks ?? 0) / kTicksPerSecond,
durationSeconds: item.RunTimeTicks == null
? null
: item.RunTimeTicks! / kTicksPerSecond,
playMethod: 'DirectStream',
);
} else {
result = await resolver.prepare(
ctx: ctx,
payload: PlaybackRequestPayload(
itemId: request.itemId,
mediaSourceId: request.mediaSourceId,
startPositionTicks: request.startPositionTicks,
playFromStart: request.playFromStart,
),
preloadedItem: request.preloadedItem,
preloadedPlaybackInfo: preloadedPlaybackInfo,
);
}
externalCancel?.throwIfCancelled();
final providerIds = providerIdsOfItem(result.item);
final parentIndexNumber = (result.item.extra['ParentIndexNumber'] as num?)
?.toInt();
final memoryKey = playbackMemoryKey(
serverId: effectiveServerId,
userId: effectiveUserId,
itemId: result.item.Id,
seriesId: result.item.SeriesId,
);
final rememberedAudioFuture =
!isDirectStream && request.audioSettings.rememberAudioTrack
? _loadRememberedAudio(memoryKey)
: Future<RememberedAudioTrack?>.value();
final rememberedSubtitleFuture = isDirectStream
? Future<RememberedSubtitleTrack?>.value()
: _loadRememberedSubtitle(memoryKey);
final directStreamHeaders = Map<String, String>.unmodifiable(
request.directStreamHeaders,
);
final streamHeaders = isDirectStream
? directStreamHeaders
: EmbyRequestHeaders.media(
deviceId: deviceId,
deviceName: deviceName,
token: effectiveToken,
userId: effectiveUserId,
);
final engineHeaders = isDirectStream
? directStreamHeaders
: EmbyRequestHeaders.directMedia();
final externalSubtitleLoader = ExternalSubtitleLoader();
if (!isDirectStream) {
unawaited(
_prefetchInitialSubtitle(
request: request,
prepareResult: result,
rememberedSubtitleFuture: rememberedSubtitleFuture,
loader: externalSubtitleLoader,
streamHeaders: streamHeaders,
),
);
}
final seriesIdentityFuture = isDirectStream
? Future<
({Map<String, String> providerIds, int? productionYear})?
>.value()
: _loadSeriesIdentity(
ctx: ctx,
serverId: effectiveServerId,
item: result.item,
providerIds: providerIds,
traktConnectedFuture: traktConnectedFuture,
externalCancel: externalCancel,
);
final probeResult = isDirectStream
? null
: await metadataResolver.resolve(
source: result.mediaSource,
streamUrl: result.streamUrl,
item: result.item,
timeout: const Duration(seconds: 5),
);
externalCancel?.throwIfCancelled();
final capability = MediaCapabilityAnalyzer.analyze(
result.mediaSource,
probeResult: probeResult,
);
final isDolbyVision =
capability.rangeCategory == VideoRangeCategory.dolbyVision;
final isHdr = switch (capability.rangeCategory) {
VideoRangeCategory.hdr10 || VideoRangeCategory.hlg => true,
_ => false,
};
final engineOverride = readEngineOverride();
final engineKind =
forcedEngineKind ??
resolvePlayerEngineKind(
engineOverride,
isDolbyVision: isDolbyVision,
videoCodec: capability.codec,
);
AppLogger.debug(
_tag,
'route override=${engineOverride.name} '
'range=${capability.rangeCategory.name} isDV=$isDolbyVision isHDR=$isHdr '
'forced=${forcedEngineKind?.name ?? '<none>'} -> ${engineKind.name}',
);
onEngineResolved?.call(engineKind, isDolbyVision);
if (priorDisposeFuture != null) {
await priorDisposeFuture;
externalCancel?.throwIfCancelled();
}
final engine = await engineHolder.acquire(
engineKind,
() => createEngine(engineKind),
);
externalCancel?.throwIfCancelled();
if (engine is FvpPlayerEngine) {
engine
..setDolbyVision(isDolbyVision)
..configureHardwareDecoding(request.playerSettings.hwdecMode);
}
final initialVolume = request.audioSettings.volumeBoost
? request.playerSettings.volume
: request.playerSettings.volume.clamp(0.0, 100.0);
await engine.setVolume(initialVolume);
externalCancel?.throwIfCancelled();
final isTranscode = result.playMethod == 'Transcode';
final startSeconds = result.startPositionSeconds;
final startDuration = (!isTranscode && startSeconds > 0)
? Duration(milliseconds: (startSeconds * 1000).round())
: Duration.zero;
externalCancel?.throwIfCancelled();
AppLogger.debug(
_tag,
'open engine=${engineKind.name} method=${result.playMethod} '
'start=${startDuration.inSeconds}s itemId=${request.itemId}',
);
final engineOpenFuture = engine.open(
result.streamUrl,
start: startDuration,
play: false,
headers: engineHeaders,
cancel: externalCancel,
);
final awaitEngineOpenInBuild = !engine.usesPlatformSurface;
final reporter = EmbyPlaybackReporter(
gateway: gateway,
context: ctx,
enabled: !isDirectStream,
);
final speedMonitor = NetworkSpeedMonitor(
totalRxBytes: engine.totalRxBytes,
);
final seriesIdentity = await seriesIdentityFuture;
externalCancel?.throwIfCancelled();
final seriesProviderIds =
seriesIdentity?.providerIds ?? const <String, String>{};
final seriesProductionYear = seriesIdentity?.productionYear;
final context = PlaybackContext(
itemId: result.item.Id,
itemName: result.item.Name,
itemType: result.item.Type,
itemIndexNumber: result.item.IndexNumber,
seriesName: result.item.SeriesName,
seriesId: result.item.SeriesId,
seasonId: result.item.SeasonId,
mediaSourceId: result.mediaSource?.Id,
playSessionId: result.playSessionId,
runTimeTicks: result.item.RunTimeTicks,
playMethod: result.playMethod,
isDolbyVision: isDolbyVision,
embySubtitles: result.subtitles,
embyAudioTracks: result.audioTracks,
allMediaSources: mediaSourcesOfItem(result.item),
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
parentIndexNumber: parentIndexNumber,
danmakuMatchContext: _buildDanmakuMatchContext(result.item),
serverId: effectiveServerId,
userId: effectiveUserId,
baseUrl: effectiveServerUrl,
token: effectiveToken,
logoUrl: _buildLogoUrl(
result.item,
baseUrl: effectiveServerUrl,
token: effectiveToken,
),
);
final rememberedAudioTrack = await rememberedAudioFuture;
final rememberedPrimarySubtitle = await rememberedSubtitleFuture;
final traktConnected = await traktConnectedFuture;
final traktReporterFactory = isDirectStream
? null
: await _buildTraktReporterFactory(
item: result.item,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
seriesProductionYear: seriesProductionYear,
parentIndexNumber: parentIndexNumber,
traktConnected: traktConnected,
);
externalCancel?.throwIfCancelled();
if (awaitEngineOpenInBuild) {
try {
await _runWithTimeout(engineOpenFuture, _engineOpenTimeout);
externalCancel?.throwIfCancelled();
} catch (_) {
try {
await engine.stop();
} catch (e) {
AppLogger.warn(_tag, 'engine.stop after open failure failed', e);
}
rethrow;
}
}
return PlaybackSession(
engine: engine,
reporter: reporter,
speedMonitor: speedMonitor,
feeder: feeder,
context: context,
request: request,
prepareResult: result,
streamHeaders: streamHeaders,
pendingEngineOpenFuture: awaitEngineOpenInBuild ? null : engineOpenFuture,
playerSettingsNotifier: playerSettingsNotifier,
traktReporterFactory: traktReporterFactory,
rememberedAudioTrack: rememberedAudioTrack,
rememberedPrimarySubtitle: rememberedPrimarySubtitle,
externalSubtitleLoader: externalSubtitleLoader,
);
}
Future<void> _prefetchInitialSubtitle({
required PlaybackRequest request,
required PlaybackRequestResult prepareResult,
required Future<RememberedSubtitleTrack?> rememberedSubtitleFuture,
required ExternalSubtitleLoader loader,
required Map<String, String> streamHeaders,
}) async {
try {
final rememberedSubtitle = await rememberedSubtitleFuture;
final selection = resolveInitialSubtitleSelection(
subtitles: prepareResult.subtitles,
preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex,
defaultSubtitleStreamIndex: prepareResult.defaultSubtitleIndex,
rememberedSubtitle: rememberedSubtitle,
);
final selectedTrack = selection.track;
final url = selectedTrack?.deliveryUrl;
final canPrefetch =
selection.type == InitialSubtitleSelectionType.track &&
selectedTrack != null &&
url != null &&
url.isNotEmpty &&
PlaybackResolver.isTextSubtitleCodec(selectedTrack.codec);
if (!canPrefetch) return;
AppLogger.debug(
_tag,
'prefetch initial subtitle index=${selectedTrack.index} '
'external=${selectedTrack.isExternal}',
);
await loader.load(
url,
headers: streamHeaders,
receiveTimeout: selectedTrack.isExternal
? null
: embeddedSubtitleFetchTimeout,
);
} catch (error) {
AppLogger.debug(_tag, 'initial subtitle prefetch failed', error);
}
}
Future<void> _runWithTimeout(Future<void> future, Duration timeout) {
return future.timeout(
timeout,
onTimeout: () {
throw TimeoutException('engine.open exceeded $timeout', timeout);
},
);
}
static PlayerEngine createEngine(PlayerEngineKind kind) {
switch (kind) {
case PlayerEngineKind.fvp:
return FvpPlayerEngine();
case PlayerEngineKind.mpv:
return MediaKitPlayerEngine();
case PlayerEngineKind.media3:
return Media3PlayerEngine();
}
}
Future<TraktScrobbleReporter Function(double Function())?>
_buildTraktReporterFactory({
required EmbyRawItem item,
required Map<String, String> providerIds,
required Map<String, String> seriesProviderIds,
required int? seriesProductionYear,
required int? parentIndexNumber,
required bool traktConnected,
}) async {
final tGateway = traktGateway;
final tQueue = traktQueue;
if (tGateway == null || tQueue == null) return null;
final target = TraktMediaMatcher.match(
itemType: item.Type,
itemName: item.Name,
productionYear: item.ProductionYear,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
seriesName: item.SeriesName,
seriesProductionYear: seriesProductionYear,
parentIndexNumber: parentIndexNumber,
itemIndexNumber: item.IndexNumber,
);
if (target == null) {
AppLogger.debug(_tag, 'trakt: no match for ${item.Type} "${item.Name}"');
return null;
}
if (!traktConnected) {
AppLogger.debug(_tag, 'trakt: not connected, scrobble disabled');
return null;
}
final TraktScrobbleTarget matched = target;
return (readProgress) => TraktScrobbleReporter(
gateway: tGateway,
queue: tQueue,
target: matched,
readProgress: readProgress,
appVersion: traktAppVersion,
onSuccess: traktOnSuccess,
onError: traktOnError,
);
}
Future<bool> _readTraktConnected() {
final tGateway = traktGateway;
if (tGateway == null || traktQueue == null) {
return Future<bool>.value(false);
}
return _traktConnectedCache ??= tGateway.isConnected().catchError((
Object e,
) {
AppLogger.debug(_tag, 'trakt: isConnected failed', e);
return false;
});
}
Future<RememberedAudioTrack?> _loadRememberedAudio(String key) async {
try {
return await audioSettingsNotifier.getLastAudioTrack(key);
} catch (e) {
AppLogger.warn(_tag, 'load remembered audio failed', e);
return null;
}
}
Future<RememberedSubtitleTrack?> _loadRememberedSubtitle(String key) async {
try {
return await subtitleSettingsNotifier.getLastSubtitleTrack(key);
} catch (e) {
AppLogger.warn(_tag, 'load remembered subtitle failed', e);
return null;
}
}
Future<({Map<String, String> providerIds, int? productionYear})?>
_loadSeriesIdentity({
required AuthedRequestContext ctx,
required String serverId,
required EmbyRawItem item,
required Map<String, String> providerIds,
required Future<bool> traktConnectedFuture,
CancellationToken? externalCancel,
}) async {
if (item.Type != 'Episode' || !(item.SeriesId?.isNotEmpty ?? false)) {
return null;
}
final traktConnected = await traktConnectedFuture;
externalCancel?.throwIfCancelled();
if (!traktConnected) return null;
try {
final loadItem = loadItemDetail;
final Future<EmbyRawItem?> future = loadItem != null
? loadItem(serverId, item.SeriesId!).then<EmbyRawItem?>((v) => v)
: gateway
.getItemDetail(ctx, item.SeriesId!)
.then<EmbyRawItem?>((v) => v);
final seriesItem = await future.timeout(
_seriesIdentitySoftTimeout,
onTimeout: () {
AppLogger.debug(
_tag,
'fetch series identity timed out after '
'${_seriesIdentitySoftTimeout.inMilliseconds}ms',
);
return null;
},
);
externalCancel?.throwIfCancelled();
if (seriesItem == null) return null;
return (
providerIds: providerIdsOfItem(seriesItem),
productionYear: seriesItem.ProductionYear,
);
} on CancelledException {
rethrow;
} catch (e) {
AppLogger.warn(_tag, 'fetch series identity failed', e);
return null;
}
}
static DanmakuMatchContext _buildDanmakuMatchContext(EmbyRawItem item) {
return DanmakuMatchContext(
itemId: item.Id,
name: item.Name,
type: item.Type,
seriesName: item.SeriesName,
season: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
episode: item.IndexNumber,
durationSec: ((item.RunTimeTicks ?? 0) / kTicksPerSecond).round(),
);
}
static String? _buildLogoUrl(
EmbyRawItem item, {
required String baseUrl,
required String token,
}) {
final isEpisode = item.Type == 'Episode';
final logoItemId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
? item.SeriesId!
: item.Id;
final logoTag = isEpisode ? null : item.ImageTags?['Logo'];
return EmbyImageUrl.logo(
baseUrl: baseUrl,
token: token,
itemId: logoItemId,
tag: logoTag,
);
}
}