Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,124 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/playback.dart';
import '../../../providers/subtitle_settings_provider.dart';
enum InitialSubtitleSelectionType { unchanged, disabled, track }
class InitialSubtitleSelection {
final InitialSubtitleSelectionType type;
final EmbySubtitleTrack? track;
const InitialSubtitleSelection._({required this.type, this.track});
static const unchanged = InitialSubtitleSelection._(
type: InitialSubtitleSelectionType.unchanged,
);
static const disabled = InitialSubtitleSelection._(
type: InitialSubtitleSelectionType.disabled,
);
const InitialSubtitleSelection.track(EmbySubtitleTrack selectedTrack)
: this._(type: InitialSubtitleSelectionType.track, track: selectedTrack);
}
InitialSubtitleSelection resolveInitialSubtitleSelection({
required List<EmbySubtitleTrack> subtitles,
required int? preferredSubtitleStreamIndex,
required int? defaultSubtitleStreamIndex,
required RememberedSubtitleTrack? rememberedSubtitle,
}) {
if (preferredSubtitleStreamIndex != null &&
preferredSubtitleStreamIndex >= 0) {
return _selectionForStreamIndex(subtitles, preferredSubtitleStreamIndex);
}
if (rememberedSubtitle != null) {
if (rememberedSubtitle.isNone) {
return InitialSubtitleSelection.disabled;
}
final rememberedStreamIndex = matchRememberedSubtitle(
rememberedSubtitle,
subtitles,
);
if (rememberedStreamIndex != null) {
return _selectionForStreamIndex(subtitles, rememberedStreamIndex);
}
}
if (preferredSubtitleStreamIndex != null &&
preferredSubtitleStreamIndex < 0) {
return InitialSubtitleSelection.disabled;
}
if (defaultSubtitleStreamIndex != null && defaultSubtitleStreamIndex >= 0) {
return _selectionForStreamIndex(subtitles, defaultSubtitleStreamIndex);
}
if (defaultSubtitleStreamIndex == -1) {
return InitialSubtitleSelection.disabled;
}
return InitialSubtitleSelection.unchanged;
}
InitialSubtitleSelection _selectionForStreamIndex(
List<EmbySubtitleTrack> subtitles,
int streamIndex,
) {
final selectedTrack = subtitles
.where((subtitle) => subtitle.index == streamIndex)
.firstOrNull;
return selectedTrack == null
? InitialSubtitleSelection.unchanged
: InitialSubtitleSelection.track(selectedTrack);
}
@visibleForTesting
int? matchRememberedSubtitle(
RememberedSubtitleTrack remembered,
List<EmbySubtitleTrack> subtitles,
) {
if (subtitles.isEmpty) return null;
final wantedLanguage = remembered.language?.toLowerCase();
final wantedTitle = remembered.title?.toLowerCase();
if (wantedLanguage != null && wantedTitle != null) {
for (final subtitle in subtitles) {
if (subtitle.language?.toLowerCase() == wantedLanguage &&
subtitle.title?.toLowerCase() == wantedTitle) {
return subtitle.index;
}
}
}
if (wantedTitle != null) {
for (final subtitle in subtitles) {
if (subtitle.title?.toLowerCase() == wantedTitle) {
return subtitle.index;
}
}
}
if (wantedLanguage != null) {
for (final subtitle in subtitles) {
if (subtitle.language?.toLowerCase() == wantedLanguage) {
return subtitle.index;
}
}
}
for (final subtitle in subtitles) {
if (subtitle.index == remembered.streamIndex) {
return subtitle.index;
}
}
return null;
}
@@ -0,0 +1,3 @@
enum PlaybackPhase { preparing, playing, ended, failed, disposed }
@@ -0,0 +1,198 @@
import '../../../core/contracts/danmaku.dart';
import '../../../core/contracts/library.dart';
import '../../../core/contracts/playback.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/subtitle_settings_provider.dart';
import 'playback_target.dart';
String playbackMemoryKey({
required String serverId,
required String userId,
required String itemId,
String? seriesId,
}) => '$serverId:$userId:${seriesId ?? itemId}';
class PlaybackRequest {
final String itemId;
final String? mediaSourceId;
final int? startPositionTicks;
final int? preferredSubtitleStreamIndex;
final int? preferredAudioStreamIndex;
final SubtitleStyleSettings subtitleSettings;
final PlayerSettingsState playerSettings;
final DanmakuSettingsState danmakuSettings;
final AudioSettingsState audioSettings;
final bool playFromStart;
final PlaybackServerIdentity? targetServer;
final EmbyRawItem? preloadedItem;
final String? directStreamUrl;
final Map<String, String> directStreamHeaders;
const PlaybackRequest({
required this.itemId,
this.mediaSourceId,
this.startPositionTicks,
this.preferredSubtitleStreamIndex,
this.preferredAudioStreamIndex,
required this.subtitleSettings,
required this.playerSettings,
required this.danmakuSettings,
this.audioSettings = const AudioSettingsState(),
this.playFromStart = false,
this.targetServer,
this.preloadedItem,
this.directStreamUrl,
this.directStreamHeaders = const <String, String>{},
});
factory PlaybackRequest.fromEpisode({
required EmbyRawItem episode,
required SubtitleStyleSettings subtitleSettings,
required PlayerSettingsState playerSettings,
required DanmakuSettingsState danmakuSettings,
AudioSettingsState audioSettings = const AudioSettingsState(),
}) {
return PlaybackRequest(
itemId: episode.Id,
startPositionTicks: episode.UserData?.PlaybackPositionTicks,
subtitleSettings: subtitleSettings,
playerSettings: playerSettings,
danmakuSettings: danmakuSettings,
audioSettings: audioSettings,
preloadedItem: episode,
);
}
PlaybackRequest copyWith({
String? itemId,
String? mediaSourceId,
int? startPositionTicks,
int? preferredSubtitleStreamIndex,
int? preferredAudioStreamIndex,
SubtitleStyleSettings? subtitleSettings,
PlayerSettingsState? playerSettings,
DanmakuSettingsState? danmakuSettings,
AudioSettingsState? audioSettings,
bool? playFromStart,
PlaybackServerIdentity? targetServer,
Object? preloadedItem = _noChange,
}) {
return PlaybackRequest(
itemId: itemId ?? this.itemId,
mediaSourceId: mediaSourceId ?? this.mediaSourceId,
startPositionTicks: startPositionTicks ?? this.startPositionTicks,
preferredSubtitleStreamIndex:
preferredSubtitleStreamIndex ?? this.preferredSubtitleStreamIndex,
preferredAudioStreamIndex:
preferredAudioStreamIndex ?? this.preferredAudioStreamIndex,
subtitleSettings: subtitleSettings ?? this.subtitleSettings,
playerSettings: playerSettings ?? this.playerSettings,
danmakuSettings: danmakuSettings ?? this.danmakuSettings,
audioSettings: audioSettings ?? this.audioSettings,
playFromStart: playFromStart ?? this.playFromStart,
targetServer: targetServer ?? this.targetServer,
preloadedItem: identical(preloadedItem, _noChange)
? this.preloadedItem
: preloadedItem as EmbyRawItem?,
directStreamUrl: directStreamUrl,
directStreamHeaders: directStreamHeaders,
);
}
}
const Object _noChange = Object();
class PlaybackContext {
final String itemId;
final String itemName;
final String? itemType;
final int? itemIndexNumber;
final String? seriesName;
final String? seriesId;
final String? seasonId;
final String? mediaSourceId;
final String? playSessionId;
final int? runTimeTicks;
final String playMethod;
final bool isDolbyVision;
final List<EmbySubtitleTrack> embySubtitles;
final List<EmbyAudioTrack> embyAudioTracks;
final List<EmbyRawMediaSource> allMediaSources;
final Map<String, String> providerIds;
final Map<String, String> seriesProviderIds;
final int? parentIndexNumber;
final DanmakuMatchContext danmakuMatchContext;
final String serverId;
final String userId;
final String baseUrl;
final String token;
final String? logoUrl;
const PlaybackContext({
required this.itemId,
required this.itemName,
required this.itemType,
required this.itemIndexNumber,
required this.seriesName,
required this.seriesId,
required this.seasonId,
required this.mediaSourceId,
required this.playSessionId,
required this.runTimeTicks,
required this.playMethod,
this.isDolbyVision = false,
required this.danmakuMatchContext,
required this.serverId,
required this.userId,
required this.baseUrl,
required this.token,
this.embySubtitles = const [],
this.embyAudioTracks = const [],
this.allMediaSources = const [],
this.providerIds = const {},
this.seriesProviderIds = const {},
this.parentIndexNumber,
this.logoUrl,
});
String get memoryKey => playbackMemoryKey(
serverId: serverId,
userId: userId,
itemId: itemId,
seriesId: seriesId,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,616 @@
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,
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/foundation.dart';
import '../../../core/contracts/library.dart';
@immutable
class PlaybackServerIdentity {
const PlaybackServerIdentity({
required this.serverId,
required this.baseUrl,
required this.token,
required this.userId,
required this.serverName,
});
factory PlaybackServerIdentity.fromCard(CrossServerSourceCard card) =>
PlaybackServerIdentity(
serverId: card.serverId,
baseUrl: card.serverUrl,
token: card.token,
userId: card.userId,
serverName: card.serverName,
);
final String serverId;
final String baseUrl;
final String token;
final String userId;
final String serverName;
@override
bool operator ==(Object other) =>
other is PlaybackServerIdentity &&
other.serverId == serverId &&
other.baseUrl == baseUrl &&
other.token == token &&
other.userId == userId &&
other.serverName == serverName;
@override
int get hashCode => Object.hash(serverId, baseUrl, token, userId, serverName);
@override
String toString() =>
'PlaybackServerIdentity(serverId=$serverId, serverName=$serverName)';
}
@@ -0,0 +1,43 @@
import '../../../core/infra/player_engine/player_engine.dart';
import 'player_engine_resolution.dart';
class PlayerEngineHolder {
PlayerEngine? _engine;
PlayerEngineKind? _kind;
PlayerEngine? get current => _engine;
Future<PlayerEngine> acquire(
PlayerEngineKind kind,
PlayerEngine Function() create,
) async {
final existing = _engine;
if (existing != null && _kind == kind) {
return existing;
}
_engine = null;
_kind = null;
if (existing != null) {
await existing.dispose();
}
final next = create();
_engine = next;
_kind = kind;
return next;
}
Future<void> dispose() async {
final engine = _engine;
_engine = null;
_kind = null;
if (engine != null) {
await engine.dispose();
}
}
}
@@ -0,0 +1,59 @@
import 'dart:io' show Platform;
import '../../../providers/player_engine_override_provider.dart';
enum PlayerEngineKind { fvp, mpv, media3 }
PlayerEngineKind? playerEngineKindFromDisplayName(String displayName) {
return switch (displayName) {
'fvp' => PlayerEngineKind.fvp,
'mpv' => PlayerEngineKind.mpv,
'media3' => PlayerEngineKind.media3,
_ => null,
};
}
PlayerEngineKind? nextAutoFallbackEngine({
required PlayerEngineOverride override,
required PlayerEngineKind? failedEngineKind,
required bool isDolbyVision,
}) {
if (override != PlayerEngineOverride.auto) return null;
return switch (failedEngineKind) {
PlayerEngineKind.media3 =>
isDolbyVision ? PlayerEngineKind.fvp : PlayerEngineKind.mpv,
PlayerEngineKind.mpv => PlayerEngineKind.fvp,
PlayerEngineKind.fvp || null => null,
};
}
PlayerEngineKind resolvePlayerEngineKind(
PlayerEngineOverride override, {
required bool isDolbyVision,
String? videoCodec,
bool? isAndroid,
}) {
final onAndroid = isAndroid ?? Platform.isAndroid;
final codec = videoCodec?.toLowerCase();
final isProRes = codec == 'prores';
switch (override) {
case PlayerEngineOverride.fvp:
return PlayerEngineKind.fvp;
case PlayerEngineOverride.mpv:
return PlayerEngineKind.mpv;
case PlayerEngineOverride.media3:
if (!onAndroid) return PlayerEngineKind.mpv;
if (isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.media3;
case PlayerEngineOverride.auto:
if (onAndroid) {
if (isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.media3;
}
if (isDolbyVision || isProRes) return PlayerEngineKind.fvp;
return PlayerEngineKind.mpv;
}
}
@@ -0,0 +1,77 @@
import 'dart:async';
class RelinkScheduler {
RelinkScheduler({
this.maxAttempts = 3,
this.backoffs = const [
Duration.zero,
Duration(seconds: 2),
Duration(seconds: 5),
],
}) : assert(maxAttempts > 0),
assert(backoffs.isNotEmpty);
final int maxAttempts;
final List<Duration> backoffs;
int _attempts = 0;
bool _pending = false;
Timer? _backoffTimer;
Timer? _sustainTimer;
bool get pending => _pending;
int get attempts => _attempts;
bool schedule({
required void Function(Duration delay) onRelink,
required void Function() onGiveUp,
}) {
if (_pending) return false;
_sustainTimer?.cancel();
if (_attempts >= maxAttempts) {
onGiveUp();
return false;
}
final backoffIndex = _attempts.clamp(0, backoffs.length - 1);
final delay = backoffs[backoffIndex];
_attempts++;
_pending = true;
_backoffTimer?.cancel();
_backoffTimer = Timer(delay, () {
_pending = false;
onRelink(delay);
});
return true;
}
void armSustain({Duration sustain = const Duration(seconds: 30)}) {
_sustainTimer?.cancel();
_sustainTimer = Timer(sustain, () => _attempts = 0);
}
void cancelPending() {
_backoffTimer?.cancel();
_pending = false;
}
void reset() => _attempts = 0;
void dispose() {
_backoffTimer?.cancel();
_sustainTimer?.cancel();
_pending = false;
}
}
@@ -0,0 +1,77 @@
class StallDetector {
StallDetector({this.threshold = const Duration(seconds: 15)})
: assert(threshold > Duration.zero);
final Duration threshold;
Duration? _lastPos;
Duration? _lastBuffer;
int? _lastBytes;
Duration _lastAdvanceAt = Duration.zero;
bool _fired = false;
bool update({
required Duration position,
required Duration bufferedPosition,
required int? bytesDownloaded,
required bool isActivelyPlaying,
required Duration now,
}) {
if (!isActivelyPlaying) {
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
_lastAdvanceAt = now;
return false;
}
if (_lastPos == null) {
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
_lastAdvanceAt = now;
return false;
}
final positionAdvanced = position != _lastPos;
final bytesBecameAvailable = bytesDownloaded != null && _lastBytes == null;
final hasComparableByteSamples =
bytesDownloaded != null && _lastBytes != null;
final bytesAdvanced =
hasComparableByteSamples && bytesDownloaded != _lastBytes;
final bufferAdvanced = bufferedPosition != _lastBuffer;
final hasBufferedMedia =
bufferedPosition - position > const Duration(seconds: 2);
final transportAdvanced = bytesBecameAvailable ||
(hasComparableByteSamples
? bytesAdvanced
: bufferAdvanced || hasBufferedMedia);
final progressed = positionAdvanced || transportAdvanced;
_lastPos = position;
_lastBuffer = bufferedPosition;
_lastBytes = bytesDownloaded;
if (progressed) {
_lastAdvanceAt = now;
_fired = false;
return false;
}
if (_fired) return false;
if (now - _lastAdvanceAt >= threshold) {
_fired = true;
return true;
}
return false;
}
void reset() {
_lastPos = null;
_lastBuffer = null;
_lastBytes = null;
_lastAdvanceAt = Duration.zero;
_fired = false;
}
}