Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user