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? _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 Function(String serverId, String itemId)? loadItemDetail; Future build({ required PlaybackRequest request, required DanmakuFeeder feeder, CancellationToken? externalCancel, Future? priorDisposeFuture, Map? 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.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.value(); final rememberedSubtitleFuture = isDirectStream ? Future.value() : _loadRememberedSubtitle(memoryKey); final directStreamHeaders = Map.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 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 ?? ''} -> ${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 {}; 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 _prefetchInitialSubtitle({ required PlaybackRequest request, required PlaybackRequestResult prepareResult, required Future rememberedSubtitleFuture, required ExternalSubtitleLoader loader, required Map 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 _runWithTimeout(Future 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 _buildTraktReporterFactory({ required EmbyRawItem item, required Map providerIds, required Map 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 _readTraktConnected() { final tGateway = traktGateway; if (tGateway == null || traktQueue == null) { return Future.value(false); } return _traktConnectedCache ??= tGateway.isConnected().catchError(( Object e, ) { AppLogger.debug(_tag, 'trakt: isConnected failed', e); return false; }); } Future _loadRememberedAudio(String key) async { try { return await audioSettingsNotifier.getLastAudioTrack(key); } catch (e) { AppLogger.warn(_tag, 'load remembered audio failed', e); return null; } } Future _loadRememberedSubtitle(String key) async { try { return await subtitleSettingsNotifier.getLastSubtitleTrack(key); } catch (e) { AppLogger.warn(_tag, 'load remembered subtitle failed', e); return null; } } Future<({Map providerIds, int? productionYear})?> _loadSeriesIdentity({ required AuthedRequestContext ctx, required String serverId, required EmbyRawItem item, required Map providerIds, required Future 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 future = loadItem != null ? loadItem(serverId, item.SeriesId!).then((v) => v) : gateway .getItemDetail(ctx, item.SeriesId!) .then((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, ); } }