fix(playback): harden offline source reporting

This commit is contained in:
edde746
2026-05-29 19:55:01 +02:00
parent 7501f461b9
commit d93ea9813f
34 changed files with 739 additions and 273 deletions
+104 -44
View File
@@ -60,7 +60,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 14;
int get schemaVersion => 15;
@override
MigrationStrategy get migration {
@@ -206,6 +206,13 @@ class AppDatabase extends _$AppDatabase {
() => m.create(idxOfflineWatchProgressProfile),
);
}
if (from < 15) {
appLogger.i('Adding mediaSourceId column to DownloadedMedia (v15 migration)');
await _ignoreAlreadyExists(
'DownloadedMedia.mediaSourceId column',
() => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId),
);
}
},
);
}
@@ -282,6 +289,52 @@ class AppDatabase extends _$AppDatabase {
.getSingleOrNull();
}
Future<List<OfflineWatchProgressItem>> getWatchActionsForKey(
String globalKey, {
String? profileId,
bool filterProfile = false,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
}
Future<Map<String, List<OfflineWatchProgressItem>>> getWatchActionsForKeys(
Set<String> globalKeys, {
String? profileId,
bool filterProfile = false,
Map<String, String?>? clientScopeIdsByGlobalKey,
}) async {
if (globalKeys.isEmpty) return const {};
final rows =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.isIn(globalKeys) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
)
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
.get();
final result = <String, List<OfflineWatchProgressItem>>{};
for (final action in rows) {
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
}
result.putIfAbsent(action.globalKey, () => <OfflineWatchProgressItem>[]).add(action);
}
return result;
}
/// Get the latest actions for multiple items in a single query
///
/// Returns a map of globalKey -> latest action for each key.
@@ -338,49 +391,53 @@ class AppDatabase extends _$AppDatabase {
final globalKey = buildGlobalKey(serverId, ratingKey);
final now = DateTime.now().millisecondsSinceEpoch;
// Check for existing progress entry
final existing =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
)
..limit(1))
.getSingleOrNull();
await transaction(() async {
final existing =
await (select(offlineWatchProgress)
..where(
(t) =>
t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id),
)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
if (existing != null) {
// Update existing progress entry
await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
),
);
} else {
// Insert new progress entry
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
final keep = existing.isEmpty ? null : existing.first;
if (keep != null) {
await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write(
OfflineWatchProgressCompanion(
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
updatedAt: Value(now),
),
);
final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false);
if (duplicateIds.isNotEmpty) {
await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go();
}
} else {
await into(offlineWatchProgress).insert(
OfflineWatchProgressCompanion.insert(
serverId: serverId,
profileId: Value(profileId),
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: globalKey,
actionType: OfflineActionType.progress.id,
viewOffset: Value(viewOffset),
duration: Value(duration),
shouldMarkWatched: Value(shouldMarkWatched),
createdAt: now,
updatedAt: now,
),
);
}
});
}
/// Insert a manual watch action (watched or unwatched).
@@ -436,11 +493,14 @@ class AppDatabase extends _$AppDatabase {
}
/// Get count of pending sync items
Future<int> getPendingSyncCount({String? profileId}) async {
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
if (profileId != null) {
query.where(offlineWatchProgress.profileId.equals(profileId));
}
if (maxSyncAttempts != null) {
query.where(offlineWatchProgress.syncAttempts.isSmallerThanValue(maxSyncAttempts));
}
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
return count ?? 0;
}
+80 -3
View File
@@ -221,6 +221,17 @@ class $DownloadedMediaTable extends DownloadedMedia
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
static const VerificationMeta _mediaSourceIdMeta = const VerificationMeta(
'mediaSourceId',
);
@override
late final GeneratedColumn<String> mediaSourceId = GeneratedColumn<String>(
'media_source_id',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -242,6 +253,7 @@ class $DownloadedMediaTable extends DownloadedMedia
retryCount,
bgTaskId,
mediaIndex,
mediaSourceId,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -397,6 +409,15 @@ class $DownloadedMediaTable extends DownloadedMedia
mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta),
);
}
if (data.containsKey('media_source_id')) {
context.handle(
_mediaSourceIdMeta,
mediaSourceId.isAcceptableOrUnknown(
data['media_source_id']!,
_mediaSourceIdMeta,
),
);
}
return context;
}
@@ -482,6 +503,10 @@ class $DownloadedMediaTable extends DownloadedMedia
DriftSqlType.int,
data['${effectivePrefix}media_index'],
)!,
mediaSourceId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}media_source_id'],
),
);
}
@@ -512,6 +537,7 @@ class DownloadedMediaItem extends DataClass
final int retryCount;
final String? bgTaskId;
final int mediaIndex;
final String? mediaSourceId;
const DownloadedMediaItem({
required this.id,
required this.serverId,
@@ -532,6 +558,7 @@ class DownloadedMediaItem extends DataClass
required this.retryCount,
this.bgTaskId,
required this.mediaIndex,
this.mediaSourceId,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -573,6 +600,9 @@ class DownloadedMediaItem extends DataClass
map['bg_task_id'] = Variable<String>(bgTaskId);
}
map['media_index'] = Variable<int>(mediaIndex);
if (!nullToAbsent || mediaSourceId != null) {
map['media_source_id'] = Variable<String>(mediaSourceId);
}
return map;
}
@@ -615,6 +645,9 @@ class DownloadedMediaItem extends DataClass
? const Value.absent()
: Value(bgTaskId),
mediaIndex: Value(mediaIndex),
mediaSourceId: mediaSourceId == null && nullToAbsent
? const Value.absent()
: Value(mediaSourceId),
);
}
@@ -645,6 +678,7 @@ class DownloadedMediaItem extends DataClass
retryCount: serializer.fromJson<int>(json['retryCount']),
bgTaskId: serializer.fromJson<String?>(json['bgTaskId']),
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
mediaSourceId: serializer.fromJson<String?>(json['mediaSourceId']),
);
}
@override
@@ -670,6 +704,7 @@ class DownloadedMediaItem extends DataClass
'retryCount': serializer.toJson<int>(retryCount),
'bgTaskId': serializer.toJson<String?>(bgTaskId),
'mediaIndex': serializer.toJson<int>(mediaIndex),
'mediaSourceId': serializer.toJson<String?>(mediaSourceId),
};
}
@@ -693,6 +728,7 @@ class DownloadedMediaItem extends DataClass
int? retryCount,
Value<String?> bgTaskId = const Value.absent(),
int? mediaIndex,
Value<String?> mediaSourceId = const Value.absent(),
}) => DownloadedMediaItem(
id: id ?? this.id,
serverId: serverId ?? this.serverId,
@@ -721,6 +757,9 @@ class DownloadedMediaItem extends DataClass
retryCount: retryCount ?? this.retryCount,
bgTaskId: bgTaskId.present ? bgTaskId.value : this.bgTaskId,
mediaIndex: mediaIndex ?? this.mediaIndex,
mediaSourceId: mediaSourceId.present
? mediaSourceId.value
: this.mediaSourceId,
);
DownloadedMediaItem copyWithCompanion(DownloadedMediaCompanion data) {
return DownloadedMediaItem(
@@ -763,6 +802,9 @@ class DownloadedMediaItem extends DataClass
mediaIndex: data.mediaIndex.present
? data.mediaIndex.value
: this.mediaIndex,
mediaSourceId: data.mediaSourceId.present
? data.mediaSourceId.value
: this.mediaSourceId,
);
}
@@ -787,7 +829,8 @@ class DownloadedMediaItem extends DataClass
..write('errorMessage: $errorMessage, ')
..write('retryCount: $retryCount, ')
..write('bgTaskId: $bgTaskId, ')
..write('mediaIndex: $mediaIndex')
..write('mediaIndex: $mediaIndex, ')
..write('mediaSourceId: $mediaSourceId')
..write(')'))
.toString();
}
@@ -813,6 +856,7 @@ class DownloadedMediaItem extends DataClass
retryCount,
bgTaskId,
mediaIndex,
mediaSourceId,
);
@override
bool operator ==(Object other) =>
@@ -836,7 +880,8 @@ class DownloadedMediaItem extends DataClass
other.errorMessage == this.errorMessage &&
other.retryCount == this.retryCount &&
other.bgTaskId == this.bgTaskId &&
other.mediaIndex == this.mediaIndex);
other.mediaIndex == this.mediaIndex &&
other.mediaSourceId == this.mediaSourceId);
}
class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
@@ -859,6 +904,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
final Value<int> retryCount;
final Value<String?> bgTaskId;
final Value<int> mediaIndex;
final Value<String?> mediaSourceId;
const DownloadedMediaCompanion({
this.id = const Value.absent(),
this.serverId = const Value.absent(),
@@ -879,6 +925,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.retryCount = const Value.absent(),
this.bgTaskId = const Value.absent(),
this.mediaIndex = const Value.absent(),
this.mediaSourceId = const Value.absent(),
});
DownloadedMediaCompanion.insert({
this.id = const Value.absent(),
@@ -900,6 +947,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.retryCount = const Value.absent(),
this.bgTaskId = const Value.absent(),
this.mediaIndex = const Value.absent(),
this.mediaSourceId = const Value.absent(),
}) : serverId = Value(serverId),
ratingKey = Value(ratingKey),
globalKey = Value(globalKey),
@@ -925,6 +973,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Expression<int>? retryCount,
Expression<String>? bgTaskId,
Expression<int>? mediaIndex,
Expression<String>? mediaSourceId,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -947,6 +996,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (retryCount != null) 'retry_count': retryCount,
if (bgTaskId != null) 'bg_task_id': bgTaskId,
if (mediaIndex != null) 'media_index': mediaIndex,
if (mediaSourceId != null) 'media_source_id': mediaSourceId,
});
}
@@ -970,6 +1020,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Value<int>? retryCount,
Value<String?>? bgTaskId,
Value<int>? mediaIndex,
Value<String?>? mediaSourceId,
}) {
return DownloadedMediaCompanion(
id: id ?? this.id,
@@ -991,6 +1042,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
retryCount: retryCount ?? this.retryCount,
bgTaskId: bgTaskId ?? this.bgTaskId,
mediaIndex: mediaIndex ?? this.mediaIndex,
mediaSourceId: mediaSourceId ?? this.mediaSourceId,
);
}
@@ -1056,6 +1108,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (mediaIndex.present) {
map['media_index'] = Variable<int>(mediaIndex.value);
}
if (mediaSourceId.present) {
map['media_source_id'] = Variable<String>(mediaSourceId.value);
}
return map;
}
@@ -1080,7 +1135,8 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
..write('errorMessage: $errorMessage, ')
..write('retryCount: $retryCount, ')
..write('bgTaskId: $bgTaskId, ')
..write('mediaIndex: $mediaIndex')
..write('mediaIndex: $mediaIndex, ')
..write('mediaSourceId: $mediaSourceId')
..write(')'))
.toString();
}
@@ -5319,6 +5375,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
Value<int> retryCount,
Value<String?> bgTaskId,
Value<int> mediaIndex,
Value<String?> mediaSourceId,
});
typedef $$DownloadedMediaTableUpdateCompanionBuilder =
DownloadedMediaCompanion Function({
@@ -5341,6 +5398,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
Value<int> retryCount,
Value<String?> bgTaskId,
Value<int> mediaIndex,
Value<String?> mediaSourceId,
});
class $$DownloadedMediaTableFilterComposer
@@ -5446,6 +5504,11 @@ class $$DownloadedMediaTableFilterComposer
column: $table.mediaIndex,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get mediaSourceId => $composableBuilder(
column: $table.mediaSourceId,
builder: (column) => ColumnFilters(column),
);
}
class $$DownloadedMediaTableOrderingComposer
@@ -5551,6 +5614,11 @@ class $$DownloadedMediaTableOrderingComposer
column: $table.mediaIndex,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get mediaSourceId => $composableBuilder(
column: $table.mediaSourceId,
builder: (column) => ColumnOrderings(column),
);
}
class $$DownloadedMediaTableAnnotationComposer
@@ -5638,6 +5706,11 @@ class $$DownloadedMediaTableAnnotationComposer
column: $table.mediaIndex,
builder: (column) => column,
);
GeneratedColumn<String> get mediaSourceId => $composableBuilder(
column: $table.mediaSourceId,
builder: (column) => column,
);
}
class $$DownloadedMediaTableTableManager
@@ -5696,6 +5769,7 @@ class $$DownloadedMediaTableTableManager
Value<int> retryCount = const Value.absent(),
Value<String?> bgTaskId = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
Value<String?> mediaSourceId = const Value.absent(),
}) => DownloadedMediaCompanion(
id: id,
serverId: serverId,
@@ -5716,6 +5790,7 @@ class $$DownloadedMediaTableTableManager
retryCount: retryCount,
bgTaskId: bgTaskId,
mediaIndex: mediaIndex,
mediaSourceId: mediaSourceId,
),
createCompanionCallback:
({
@@ -5738,6 +5813,7 @@ class $$DownloadedMediaTableTableManager
Value<int> retryCount = const Value.absent(),
Value<String?> bgTaskId = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
Value<String?> mediaSourceId = const Value.absent(),
}) => DownloadedMediaCompanion.insert(
id: id,
serverId: serverId,
@@ -5758,6 +5834,7 @@ class $$DownloadedMediaTableTableManager
retryCount: retryCount,
bgTaskId: bgTaskId,
mediaIndex: mediaIndex,
mediaSourceId: mediaSourceId,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
+8
View File
@@ -85,6 +85,7 @@ extension DownloadDatabaseOperations on AppDatabase {
String? grandparentRatingKey,
required int status,
int mediaIndex = 0,
String? mediaSourceId,
}) async {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
@@ -97,6 +98,7 @@ extension DownloadDatabaseOperations on AppDatabase {
grandparentRatingKey: Value(grandparentRatingKey),
status: status,
mediaIndex: Value(mediaIndex),
mediaSourceId: Value(mediaSourceId),
),
mode: InsertMode.insertOrReplace,
);
@@ -145,6 +147,12 @@ extension DownloadDatabaseOperations on AppDatabase {
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
}
Future<void> updateDownloadMediaSource(String globalKey, String? mediaSourceId) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)),
);
}
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
DownloadedMediaCompanion(
+1
View File
@@ -59,6 +59,7 @@ class DownloadedMedia extends Table {
IntColumn get retryCount => integer().withDefault(const Constant(0))();
TextColumn get bgTaskId => text().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
TextColumn get mediaSourceId => text().nullable()();
}
/// Profile ownership for shared physical downloads.
+7 -2
View File
@@ -73,6 +73,7 @@ import 'utils/media_server_http_client.dart';
import 'utils/orientation_helper.dart';
import 'utils/watch_state_notifier.dart';
import 'i18n/strings.g.dart';
import 'media/media_server_client.dart';
import 'focus/input_mode_tracker.dart';
import 'focus/key_event_utils.dart';
import 'package:intl/date_symbol_data_local.dart';
@@ -764,11 +765,15 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider;
},
),
ChangeNotifierProxyProvider<ActiveProfileProvider, WatchStateOverlayProvider>(
ChangeNotifierProxyProvider2<ActiveProfileProvider, MultiServerProvider, WatchStateOverlayProvider>(
create: (_) => WatchStateOverlayProvider(),
update: (_, activeProfile, previous) {
update: (_, activeProfile, multiServer, previous) {
final provider = previous ?? WatchStateOverlayProvider();
provider.setActiveProfileId(activeProfile.activeId);
provider.setActiveClientScopesByServer({
for (final serverId in multiServer.serverManager.serverIds)
serverId: multiServer.serverManager.getClient(serverId)?.cacheServerId,
});
return provider;
},
),
+2 -1
View File
@@ -47,7 +47,8 @@ class DownloadArtworkSpec {
/// version.
class DownloadResolution {
final String? videoUrl;
final String? mediaSourceId;
final List<DownloadSubtitleSpec> externalSubtitles;
const DownloadResolution({required this.videoUrl, this.externalSubtitles = const []});
const DownloadResolution({required this.videoUrl, this.mediaSourceId, this.externalSubtitles = const []});
}
+4 -6
View File
@@ -18,6 +18,7 @@ import 'media_item.dart';
import 'media_kind.dart';
import 'media_library.dart';
import 'media_playlist.dart';
import 'playback_report_metadata.dart';
import 'server_capabilities.dart';
/// Backend-neutral client for a single media server (Plex or Jellyfin).
@@ -493,18 +494,15 @@ abstract class MediaServerClient {
});
/// End-of-session signal. Plex sends `state=stopped`; Jellyfin closes
/// the session row. [offline] and [updatedAt] are used by Plex when replaying
/// queued offline watch progress; backends that have no equivalent may ignore
/// them.
/// the session row. [report] carries semantic metadata such as offline
/// replay timing without leaking backend-specific wire parameter names.
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
});
/// Resolve the video URL, media info, and external subtitle list for
+23
View File
@@ -0,0 +1,23 @@
/// Backend-neutral metadata attached to a playback report.
///
/// This deliberately describes user/client intent rather than backend wire
/// parameters. Plex maps offline replays to `offline`, `updated`, and
/// `continuing` timeline query params; backends without equivalent semantics
/// can ignore the fields.
enum PlaybackReportOrigin { live, offlineReplay }
class PlaybackReportMetadata {
final PlaybackReportOrigin origin;
final DateTime? recordedAt;
final bool? willContinue;
const PlaybackReportMetadata({this.origin = PlaybackReportOrigin.live, this.recordedAt, this.willContinue});
const PlaybackReportMetadata.live({bool? willContinue})
: this(origin: PlaybackReportOrigin.live, willContinue: willContinue);
const PlaybackReportMetadata.offlineReplay({required DateTime recordedAt, bool willContinue = false})
: this(origin: PlaybackReportOrigin.offlineReplay, recordedAt: recordedAt, willContinue: willContinue);
bool get isOfflineReplay => origin == PlaybackReportOrigin.offlineReplay;
}
+31 -18
View File
@@ -17,6 +17,7 @@ import '../services/download_storage_service.dart';
import '../services/multi_server_manager.dart';
import '../services/offline_mode_source.dart';
import '../services/storage_service.dart';
import '../services/watch_state_resolver.dart';
import '../media/media_server_client.dart';
import '../services/sync_rule_executor.dart';
import '../utils/app_logger.dart';
@@ -361,7 +362,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
scopes[key] = await _offlineWatchScopeForGlobalKey(key);
}
final profileId = _activeProfileId;
final actions = await _database.getLatestWatchActionsForKeys(
final actions = await _database.getWatchActionsForKeys(
keys,
profileId: profileId,
filterProfile: profileId != null,
@@ -371,22 +372,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
for (final entry in actions.entries) {
final base = _metadata[entry.key];
if (base == null) continue;
final action = entry.value;
bool? isWatched;
int? viewOffsetMs;
switch (action.actionType) {
case 'watched':
isWatched = true;
viewOffsetMs = 0;
case 'unwatched':
isWatched = false;
viewOffsetMs = 0;
case 'progress':
isWatched = action.shouldMarkWatched;
viewOffsetMs = action.shouldMarkWatched ? 0 : action.viewOffset;
}
if (isWatched == null) continue;
_metadata[entry.key] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: viewOffsetMs);
final snapshot = WatchStateResolver.fromActions(entry.value);
if (snapshot.isEmpty) continue;
_metadata[entry.key] = snapshot.apply(base);
}
} catch (e) {
appLogger.w('Failed to apply offline watch overlay', error: e);
@@ -503,6 +491,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final globalKey = buildGlobalKey(event.serverId, event.itemId);
final base = _metadata[globalKey];
if (base == null) return;
final eventScope = event.cacheServerId;
final activeScope = _downloadManager.activeClientScopeIdForServer(event.serverId);
if (eventScope != null && eventScope.isNotEmpty && eventScope != event.serverId && eventScope != activeScope) {
return;
}
final isWatched = event.isNowWatched!;
_metadata[globalKey] = base.copyWith(viewCount: isWatched ? 1 : 0, viewOffsetMs: 0);
@@ -829,7 +822,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Get the local video file path for a downloaded item
/// Returns null if not downloaded or file doesn't exist
Future<String?> getVideoFilePath(String globalKey) async {
Future<String?> getVideoFilePath(String globalKey, {int? mediaIndex, String? mediaSourceId}) async {
appLogger.d('getVideoFilePath called with globalKey: $globalKey');
if (!_ownsDownloadKey(globalKey)) {
appLogger.w('Profile does not own downloaded item: $globalKey');
@@ -845,6 +838,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
appLogger.w('Download not complete. Status: ${downloadedItem.status}');
return null;
}
final expectedSourceId = mediaSourceId?.trim();
final downloadedSourceId = downloadedItem.mediaSourceId;
if (expectedSourceId != null &&
expectedSourceId.isNotEmpty &&
downloadedSourceId != null &&
downloadedSourceId.isNotEmpty &&
expectedSourceId != downloadedSourceId) {
appLogger.w(
'Downloaded media source mismatch for $globalKey: have $downloadedSourceId, expected $expectedSourceId',
);
return null;
}
if ((downloadedSourceId == null || downloadedSourceId.isEmpty) &&
mediaIndex != null &&
downloadedItem.mediaIndex != mediaIndex) {
appLogger.w(
'Downloaded media index mismatch for $globalKey: have ${downloadedItem.mediaIndex}, expected $mediaIndex',
);
return null;
}
if (downloadedItem.videoFilePath == null) {
appLogger.w('Video file path is null for globalKey: $globalKey');
return null;
+36 -21
View File
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import '../media/media_item.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
import '../services/watch_state_resolver.dart';
import '../utils/global_key_utils.dart';
import '../utils/watch_state_notifier.dart';
@immutable
@@ -14,6 +16,12 @@ class WatchStateOverlayPatch {
const WatchStateOverlayPatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
factory WatchStateOverlayPatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStateOverlayPatch(
isWatched: snapshot.isWatched,
hasViewOffsetMs: snapshot.hasViewOffsetMs,
viewOffsetMs: snapshot.viewOffsetMs,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -38,8 +46,19 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
StreamSubscription<WatchStateEvent>? _subscription;
final Map<String, WatchStateOverlayPatch> _patches = {};
String? _activeProfileId;
Map<String, String?> _activeClientScopesByServer = const {};
WatchStateOverlayPatch? patchForGlobalKey(String globalKey) => _patches[globalKey];
WatchStateOverlayPatch? patchForGlobalKey(String globalKey) {
final parsed = parseGlobalKey(globalKey);
if (parsed != null) {
final scoped = _activeClientScopesByServer[parsed.serverId];
if (scoped != null && scoped.isNotEmpty) {
final scopedPatch = _patches[buildGlobalKey(scoped, parsed.ratingKey)];
if (scopedPatch != null) return scopedPatch;
}
}
return _patches[globalKey];
}
WatchStateOverlayPatch? patchForItem(MediaItem item) => patchForGlobalKey(item.globalKey);
@@ -69,29 +88,25 @@ class WatchStateOverlayProvider extends ChangeNotifier with DisposableChangeNoti
safeNotifyListeners();
}
void _onWatchStateEvent(WatchStateEvent event) {
final patch = switch (event.changeType) {
WatchStateChangeType.watched => const WatchStateOverlayPatch(
isWatched: true,
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.unwatched => const WatchStateOverlayPatch(
isWatched: false,
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.progressUpdate =>
event.isNowWatched == true
? const WatchStateOverlayPatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0)
: WatchStateOverlayPatch(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset),
WatchStateChangeType.removedFromContinueWatching => null,
void setActiveClientScopesByServer(Map<String, String?> scopes) {
final normalized = <String, String?>{
for (final entry in scopes.entries)
if (entry.value != null && entry.value!.isNotEmpty && entry.value != entry.key) entry.key: entry.value,
};
if (mapEquals(_activeClientScopesByServer, normalized)) return;
_activeClientScopesByServer = Map.unmodifiable(normalized);
if (_patches.isNotEmpty) safeNotifyListeners();
}
if (patch == null) return;
void _onWatchStateEvent(WatchStateEvent event) {
final patch = WatchStateOverlayPatch.fromSnapshot(WatchStateResolver.fromEvent(event));
if (_patches[event.globalKey] == patch) return;
_patches[event.globalKey] = patch;
final cacheServerId = event.cacheServerId;
final key = cacheServerId != null && cacheServerId.isNotEmpty && cacheServerId != event.serverId
? buildGlobalKey(cacheServerId, event.itemId)
: event.globalKey;
if (_patches[key] == patch) return;
_patches[key] = patch;
safeNotifyListeners();
}
@@ -135,6 +135,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Future<void> _swapEpisodeInPip(MediaItem episodeMetadata) async {
_isSwappingEpisode = true;
final currentPlayer = player!;
final playbackGeneration = _beginPlaybackGeneration();
final previousMetadata = _currentMetadata;
final currentAudioTrack = currentPlayer.state.track.audio;
@@ -146,13 +147,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// backend. We still narrow to [plexClient] for [TrackManager]'s
// server-side track persistence, which is Plex-only — Jellyfin
// sessions get a null `getPlexClient` and skip that path.
final mediaClient = _getOnlineMediaServerClient(context);
final plexClient = mediaClient is PlexClient ? mediaClient : null;
final streamHeaders = mediaClient?.streamHeaders;
final offlineWatchService = context.read<OfflineWatchSyncService>();
final userProfileProvider = context.read<UserProfileProvider>();
final playbackState = context.read<PlaybackStateProvider>();
final database = context.read<AppDatabase>();
final serverManager = context.read<MultiServerProvider>().serverManager;
await _sendStoppedProgressOnce();
_progressTracker?.stopTracking();
@@ -169,19 +168,21 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_hasFirstFrame.value = false;
try {
// Same service shape works for both online (mediaClient non-null,
// bundled video URL + media info) and pure-offline (mediaClient null,
// local file + cached media info if available).
final playbackService = PlaybackInitializationService(client: mediaClient, database: database);
final result = await playbackService.getPlaybackData(
final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database);
final playbackContext = await playbackResolver.resolve(
metadata: episodeMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
preferOffline: widget.isOffline || _selectedQualityPreset.isOriginal,
selectedMediaSourceId: widget.selectedMediaSourceId,
offlineLibraryMode: widget.isOffline,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
final result = playbackContext.result;
final mediaClient = playbackContext.reportingClient;
final plexClient = mediaClient is PlexClient ? mediaClient : null;
final streamHeaders = playbackContext.streamHeaders;
if (result.videoUrl == null) {
throw PlaybackException('No video URL available');
@@ -190,6 +191,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Duration? resumePosition;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackContext = playbackContext;
_playbackPlaySessionId = result.playSessionId;
_playbackPlayMethod = result.playMethod;
_selectedAudioStreamId = result.activeAudioStreamId;
@@ -234,7 +236,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
_completionTriggered = false;
_isSwappingEpisode = false;
if (!mounted) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
_scrubPreviewSource?.dispose();
_setPlayerState(() {
@@ -88,7 +88,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
void _cancelAutoPlay() {
_autoPlayTimer?.cancel();
_stoppedProgressFuture = null;
_progressTracker?.resumeAfterStoppedReport();
_completionTriggered = false; // Reset so it can trigger again if user seeks near end
_setPlayerState(() {
_showPlayNextDialog = false;
@@ -20,7 +20,6 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
}) {
final currentPlayer = player;
if (currentPlayer == null) return;
_stoppedProgressFuture = null;
// Progress tracker — local media still reports live when its server is
// online; only queue locally when no reporting client is reachable.
@@ -30,7 +29,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
metadata: metadata,
player: currentPlayer,
offlineWatchService: offlineWatchService,
queueOnOnlineFailure: _usesLocalPlaybackSource,
queueOnOnlineFailure: _playbackContext?.shouldQueueOnReportFailure ?? _usesLocalPlaybackSource,
playMethod: playMethod ?? (_isTranscoding ? 'Transcode' : 'DirectPlay'),
playSessionId: playSessionId,
mediaInfo: mediaInfo,
@@ -82,7 +81,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
// Get a live reporting client when possible. Downloaded/local playback
// still uses this path when the server is reachable.
final mediaClient = _getOnlineMediaServerClient(context);
final mediaClient = _playbackContext?.reportingClient ?? _getOnlineMediaServerClient(context);
final offlineWatchService = context.read<OfflineWatchSyncService>();
// Initialize media controls manager (must exist before the per-item
@@ -4,6 +4,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
Future<void> _startPlayback() async {
final currentPlayer = player;
if (!mounted || currentPlayer == null) return;
final playbackGeneration = _beginPlaybackGeneration();
// Live TV mode: bypass standard playback initialization
if (widget.isLive) {
@@ -11,7 +12,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_hasFirstFrame.value = false;
await currentPlayer.requestAudioFocus();
await _setLiveStreamOptions();
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
String streamUrl;
if (_liveStreamUrl != null) {
@@ -97,7 +98,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_livePlaybackStartTime = DateTime.now();
await currentPlayer.setProperty('force-seekable', 'no');
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
_trackManager?.cacheExternalSubtitles(const []);
@@ -128,30 +129,29 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
try {
PlaybackInitializationResult result;
PlaybackContext playbackContext;
Map<String, String>? streamHeaders;
if (widget.isOffline) {
// Offline mode: route through PlaybackInitializationService with a
// (possibly null) cached client. The service reads cached media
// info via the client when available, falls back to local file +
// sidecar subtitles otherwise.
final cachedSourceClient = _getOnlineMediaServerClient(context);
final offlineService = PlaybackInitializationService(
client: cachedSourceClient,
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
result = await offlineService.getPlaybackData(
playbackContext = await playbackResolver.resolve(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: true,
offlineLibraryMode: true,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
transcodeSessionId: _playbackTranscodeSessionId,
);
result = playbackContext.result;
if (result.videoUrl == null) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
if (!result.usesLocalMedia) {
streamHeaders = cachedSourceClient?.streamHeaders;
}
streamHeaders = playbackContext.streamHeaders;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
_playbackPlaySessionId = result.playSessionId;
@@ -166,11 +166,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
if (playbackDataFuture == null) {
throw StateError('Playback data was not prepared before playback start');
}
result = await playbackDataFuture;
playbackContext = await playbackDataFuture;
result = playbackContext.result;
if (!mounted || player != currentPlayer) return;
if (result.usesLocalMedia) {
streamHeaders = null;
}
streamHeaders = playbackContext.streamHeaders;
_isTranscoding = result.isTranscoding;
_effectiveIsOffline = result.isOffline;
@@ -186,12 +185,13 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
_selectedQualityPreset = TranscodeQualityPreset.original;
}
}
_playbackContext = playbackContext;
// Primary refresh-rate path: when metadata provides FPS, Android MPV can
// switch before `loadfile`; ExoPlayer and MPV fallback cases still open
// paused and switch before visible playback starts.
final settingsService = await SettingsService.getInstance();
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
final displayCriteria = result.mediaInfo?.displayCriteria;
final preKnownFps = displayCriteria?.fps;
final willAutoSwitch =
@@ -268,7 +268,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
} else {
await currentPlayer.requestAudioFocus();
}
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
// Pass resume position if available.
// In offline mode, prefer locally tracked progress over the cached server value
@@ -335,7 +335,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
timelineOffset: openTiming.timelineOffset,
timelineDuration: openTiming.timelineDuration,
);
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
// Apply subtitle styling to ExoPlayer native layer (CaptionStyleCompat + libass font scale)
// Must be called after open() since that's when ExoPlayer initializes
@@ -394,7 +394,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
}
await _initVideoFilterAndPip();
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
if (player == currentPlayer) {
// Auto-PiP: set up callback for API 26-30 path and initial state
@@ -424,7 +424,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
await _restoreAmbientLighting();
}
}
if (!mounted || player != currentPlayer) return;
if (!_isCurrentPlaybackGeneration(playbackGeneration, currentPlayer)) return;
// Track manager: owns track selection, external subtitle loading, and Plex
// immediate stream writes. Jellyfin persists selected stream indexes through
+16 -15
View File
@@ -44,7 +44,9 @@ import '../services/episode_navigation_service.dart';
import '../services/app_foreground_service.dart';
import '../services/media_controls_manager.dart';
import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart';
import '../services/playback_progress_tracker.dart';
import '../services/playback_source_resolver.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/display_mode_service.dart';
import '../services/settings_service.dart';
@@ -274,7 +276,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// the metadata fetch (and transcode-decision HTTP, if non-original preset)
// overlaps with MPV property configuration. Awaited inside `_startPlayback`
// immediately before `player.open()` needs the video URL.
Future<PlaybackInitializationResult>? _playbackDataFuture;
Future<PlaybackContext>? _playbackDataFuture;
PlaybackContext? _playbackContext;
int _playbackGeneration = 0;
// HTTP headers attached to the player's `Media` request — `X-Plex-Token`
// for Plex, empty for Jellyfin (token rides in the URL there). Sourced
// from `MediaServerClient.streamHeaders` so the player code path stays
@@ -380,7 +384,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
int _rewindOnResume = 0;
Future<void> _lifecycleTransition = Future<void>.value();
String _playerBackendLabel = 'unknown';
Future<void>? _stoppedProgressFuture;
Timer? _tvBackgroundMediaControlResumeTimer;
/// Whether to skip lifecycle actions because PiP is active or about to start.
@@ -434,6 +437,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
ScrubFrame? _getThumbnailData(Duration time) => _scrubPreviewSource?.getFrame(time);
int _beginPlaybackGeneration() => ++_playbackGeneration;
bool _isCurrentPlaybackGeneration(int generation, Player currentPlayer) {
return mounted && player == currentPlayer && _playbackGeneration == generation;
}
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
@@ -636,15 +645,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} else {
_selectedQualityPreset = widget.selectedQualityPreset!;
}
final playbackService = PlaybackInitializationService(
client: genericClient,
final playbackResolver = PlaybackSourceResolver(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
);
_playbackDataFuture = playbackService.getPlaybackData(
_playbackDataFuture = playbackResolver.resolve(
metadata: _currentMetadata,
selectedMediaIndex: widget.selectedMediaIndex,
selectedMediaSourceId: widget.selectedMediaSourceId,
preferOffline: _selectedQualityPreset.isOriginal,
offlineLibraryMode: false,
qualityPreset: _selectedQualityPreset,
selectedAudioStreamId: _selectedAudioStreamId,
sessionIdentifier: _playbackSessionIdentifier,
@@ -1269,20 +1278,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
String get playbackTranscodeSessionId => _playbackTranscodeSessionId;
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
final existing = _stoppedProgressFuture;
if (existing != null) return existing;
final tracker = _progressTracker;
if (tracker == null) return Future<void>.value();
final future = tracker.sendProgress('stopped', positionOverride: positionOverride).catchError((
Object e,
StackTrace st,
) {
return tracker.sendStoppedProgressOnce(positionOverride: positionOverride).catchError((Object e, StackTrace st) {
appLogger.d('Stopped progress flush failed', error: e, stackTrace: st);
});
_stoppedProgressFuture = future;
return future;
}
/// Dispose the player before replacing the video to avoid race conditions
@@ -1025,6 +1025,7 @@ class DownloadManagerService {
grandparentRatingKey: metadata.grandparentId,
status: DownloadStatus.queued.index,
mediaIndex: mediaIndex,
mediaSourceId: _mediaSourceIdForIndex(metadata, mediaIndex),
);
// Populate the offline cache via the read path and pin so the row
@@ -1044,6 +1045,13 @@ class DownloadManagerService {
unawaited(_processQueue(client));
}
String? _mediaSourceIdForIndex(MediaItem metadata, int mediaIndex) {
final versions = metadata.mediaVersions;
if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) return null;
final id = versions[mediaIndex].id.trim();
return id.isEmpty ? null : id;
}
/// Process the download queue — prepares and enqueues items with background_downloader.
/// Non-blocking: returns after all queued items are enqueued (downloads run natively).
Future<void> _processQueue(MediaServerClient client) async {
@@ -1250,6 +1258,9 @@ class DownloadManagerService {
resolution = await client.resolveDownload(metadata, mediaIndex: selectedMediaIndex);
if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey');
}
if (resolution.mediaSourceId != null && resolution.mediaSourceId != existing.mediaSourceId) {
await _database.updateDownloadMediaSource(globalKey, resolution.mediaSourceId);
}
if (await _isCancelledOrDeleted(globalKey)) {
appLogger.d('Skipping enqueue for $globalKey: cancelled during preparation');
+43 -18
View File
@@ -12,6 +12,8 @@ import '../utils/snackbar_helper.dart';
import '../utils/watch_state_notifier.dart';
import '../i18n/strings.g.dart';
import 'settings_service.dart';
import 'offline_watch_sync_service.dart';
import 'playback_report_session.dart';
import 'trackers/tracker_coordinator.dart';
const _externalPlayerChannel = MethodChannel('com.plezy/external_player');
@@ -60,6 +62,7 @@ class ExternalPlayerService {
required BuildContext context,
MediaItem? metadata,
MediaServerClient? client,
OfflineWatchSyncService? offlineWatchService,
int mediaIndex = 0,
String? mediaSourceId,
String? videoUrl,
@@ -93,11 +96,12 @@ class ExternalPlayerService {
// On Android, always use native intent to avoid url_launcher opening in browser
if (Platform.isAndroid && context.mounted) {
final launchResult = await _launchAndroidNative(resolvedUrl, player, context, metadata: metadata);
if (launchResult.launched && metadata != null && client != null) {
if (launchResult.launched && metadata != null) {
await _reportAndroidExternalProgress(
launchResult,
metadata: metadata,
client: client,
offlineWatchService: offlineWatchService,
mediaSourceId: mediaSourceId,
);
}
@@ -147,7 +151,8 @@ class ExternalPlayerService {
static Future<void> _reportAndroidExternalProgress(
_ExternalPlayerLaunchResult result, {
required MediaItem metadata,
required MediaServerClient client,
required MediaServerClient? client,
OfflineWatchSyncService? offlineWatchService,
String? mediaSourceId,
}) async {
if (result.playbackError) {
@@ -162,25 +167,28 @@ class ExternalPlayerService {
final positionMs = durationMs == null ? reportedPositionMs : reportedPositionMs.clamp(0, durationMs).toInt();
final position = Duration(milliseconds: positionMs);
final duration = durationMs == null ? null : Duration(milliseconds: durationMs);
if (client == null) {
await _queueExternalProgress(metadata, offlineWatchService, position: position, duration: duration);
return;
}
try {
try {
await client.reportPlaybackStarted(
itemId: metadata.id,
final session = PlaybackReportSession(client: client, itemId: metadata.id, playMethod: 'DirectPlay');
await session.report(
PlaybackReportSnapshot(
state: 'playing',
position: position,
duration: duration,
playMethod: 'DirectPlay',
mediaSourceId: mediaSourceId,
);
} catch (e) {
appLogger.d('External player progress: started call failed (continuing)', error: e);
}
await client.reportPlaybackStopped(
itemId: metadata.id,
position: position,
duration: duration,
mediaSourceId: mediaSourceId,
duration: duration ?? position,
resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId),
),
);
await session.report(
PlaybackReportSnapshot(
state: 'stopped',
position: position,
duration: duration ?? position,
resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId),
),
);
if (duration == null) return;
@@ -198,9 +206,26 @@ class ExternalPlayerService {
}
} catch (e) {
appLogger.w('Failed to sync external player progress for ${metadata.id}', error: e);
await _queueExternalProgress(metadata, offlineWatchService, position: position, duration: duration);
}
}
static Future<void> _queueExternalProgress(
MediaItem metadata,
OfflineWatchSyncService? offlineWatchService, {
required Duration position,
required Duration? duration,
}) async {
final serverId = metadata.serverId;
if (offlineWatchService == null || serverId == null || duration == null || duration.inMilliseconds <= 0) return;
await offlineWatchService.queueProgressUpdate(
serverId: serverId,
itemId: metadata.id,
viewOffset: position.inMilliseconds.clamp(0, duration.inMilliseconds).toInt(),
duration: duration.inMilliseconds,
);
}
static int? _positive(int? value) => value != null && value > 0 ? value : null;
/// Map known player IDs to their Android package names.
+1
View File
@@ -21,6 +21,7 @@ import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_channel.dart';
@@ -116,7 +116,7 @@ mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
}
}
return DownloadResolution(videoUrl: videoUrl, externalSubtitles: subtitles);
return DownloadResolution(videoUrl: videoUrl, mediaSourceId: selectedSourceId, externalSubtitles: subtitles);
}
Map<String, dynamic>? _selectDownloadMediaSource(List<dynamic> sources, String? selectedSourceId, int mediaIndex) {
@@ -647,9 +647,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
final response = await _http.post(
'/Sessions/Playing/Stopped',
+18 -48
View File
@@ -9,6 +9,7 @@ import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import 'offline_mode_source.dart';
@@ -17,6 +18,7 @@ import 'multi_server_manager.dart';
import 'plex_client.dart';
import 'settings_service.dart';
import 'trackers/tracker_coordinator.dart';
import 'watch_state_resolver.dart';
/// Service for managing offline watch progress and syncing it back to the
/// owning server. Backend-neutral over [MediaServerClient] — Plex actions
@@ -275,31 +277,19 @@ class OfflineWatchSyncService extends ChangeNotifier {
/// Returns:
/// - `true` if item was marked as watched locally or progress >= server threshold
/// - `false` if item was marked as unwatched locally
/// - `null` if no local action exists (use cached server data)
/// - `null` if no local watched/unwatched action exists (use cached server data)
Future<bool?> getLocalWatchStatus(String globalKey, {String? clientScopeId}) async {
await _adoptLegacyWatchActionsForActiveProfile();
final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey);
final profileId = _activeProfileId;
final action = await _database.getLatestWatchAction(
final actions = await _database.getWatchActionsForKey(
globalKey,
profileId: profileId,
filterProfile: profileId != null,
clientScopeId: expectedScope,
filterClientScope: expectedScope != null,
);
if (action == null) return null;
switch (action.actionType) {
case 'watched':
return true;
case 'unwatched':
return false;
case 'progress':
// Check if progress exceeds threshold
return action.shouldMarkWatched;
default:
return null;
}
return WatchStateResolver.fromActions(actions).isWatched;
}
/// Get local watch statuses for multiple items in a single database query.
@@ -315,7 +305,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
final scopes = clientScopeIdsByGlobalKey ?? _activeClientScopeIdsForGlobalKeys(globalKeys);
final profileId = _activeProfileId;
final actions = await _database.getLatestWatchActionsForKeys(
final actions = await _database.getWatchActionsForKeys(
globalKeys,
profileId: profileId,
filterProfile: profileId != null,
@@ -324,22 +314,7 @@ class OfflineWatchSyncService extends ChangeNotifier {
final result = <String, bool?>{};
for (final key in globalKeys) {
final action = actions[key];
if (action == null) {
result[key] = null;
continue;
}
switch (action.actionType) {
case 'watched':
result[key] = true;
case 'unwatched':
result[key] = false;
case 'progress':
result[key] = action.shouldMarkWatched;
default:
result[key] = null;
}
result[key] = WatchStateResolver.fromActions(actions[key] ?? const []).isWatched;
}
return result;
@@ -347,35 +322,30 @@ class OfflineWatchSyncService extends ChangeNotifier {
/// Get the local view offset (resume position) for a media item.
///
/// Returns the locally tracked position, or null if none exists.
/// Returns the locally tracked position, or null if none exists. Explicit
/// watched/unwatched actions clear resume by resolving to a zero offset.
Future<int?> getLocalViewOffset(String globalKey, {String? clientScopeId}) async {
await _adoptLegacyWatchActionsForActiveProfile();
final expectedScope = clientScopeId ?? _activeClientScopeIdForGlobalKey(globalKey);
final profileId = _activeProfileId;
final action = await _database.getLatestWatchAction(
final actions = await _database.getWatchActionsForKey(
globalKey,
profileId: profileId,
filterProfile: profileId != null,
clientScopeId: expectedScope,
filterClientScope: expectedScope != null,
);
if (action == null) return null;
// Only return offset for progress actions
if (action.actionType == OfflineActionType.progress.id) {
if (action.shouldMarkWatched) return null;
return action.viewOffset;
}
return null;
final snapshot = WatchStateResolver.fromActions(actions);
final offset = snapshot.hasViewOffsetMs ? snapshot.viewOffsetMs : null;
return offset != null && offset > 0 ? offset : null;
}
Future<int> getPendingSyncCount() async {
await _adoptLegacyWatchActionsForActiveProfile();
final profileId = _activeProfileId;
return profileId == null || profileId.isEmpty
? _database.getPendingSyncCount()
: _database.getPendingSyncCount(profileId: profileId);
? _database.getPendingSyncCount(maxSyncAttempts: maxSyncAttempts)
: _database.getPendingSyncCount(profileId: profileId, maxSyncAttempts: maxSyncAttempts);
}
/// Sync all pending items to their respective servers.
@@ -599,9 +569,9 @@ class OfflineWatchSyncService extends ChangeNotifier {
itemId: action.ratingKey,
position: position,
duration: duration,
offline: true,
updatedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt),
continuing: false,
report: PlaybackReportMetadata.offlineReplay(
recordedAt: DateTime.fromMillisecondsSinceEpoch(action.updatedAt),
),
);
}
+31
View File
@@ -0,0 +1,31 @@
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import 'playback_initialization_types.dart';
enum PlaybackSourceKind { localFile, remoteDirect, remoteTranscode }
enum PlaybackReportingMode { online, offlineQueue, onlineWithOfflineFallback, disabled }
class PlaybackContext {
final MediaItem metadata;
final PlaybackInitializationResult result;
final PlaybackSourceKind sourceKind;
final PlaybackReportingMode reportingMode;
final MediaServerClient? reportingClient;
final String? clientScopeId;
final Map<String, String>? streamHeaders;
const PlaybackContext({
required this.metadata,
required this.result,
required this.sourceKind,
required this.reportingMode,
this.reportingClient,
this.clientScopeId,
this.streamHeaders,
});
bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile;
bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback;
bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue;
}
@@ -45,7 +45,12 @@ class PlaybackInitializationService {
///
/// Returns the local file path if the video is downloaded and completed.
/// Returns null if not available offline or database is not provided.
Future<String?> getOfflineVideoPath(String serverId, String ratingKey, {int mediaIndex = 0}) async {
Future<String?> getOfflineVideoPath(
String serverId,
String ratingKey, {
int mediaIndex = 0,
String? selectedMediaSourceId,
}) async {
if (database == null) {
return null;
}
@@ -64,8 +69,22 @@ class PlaybackInitializationService {
return null;
}
// Skip offline file if a different version was requested
if (downloadedItem.mediaIndex != mediaIndex) {
final downloadedSourceId = downloadedItem.mediaSourceId;
final requestedSourceId = selectedMediaSourceId?.trim();
if (requestedSourceId != null &&
requestedSourceId.isNotEmpty &&
downloadedSourceId != null &&
downloadedSourceId.isNotEmpty &&
downloadedSourceId != requestedSourceId) {
appLogger.d(
'[VersionTrace] Offline video source is $downloadedSourceId, '
'but requested source $requestedSourceId — skipping offline',
);
return null;
}
// Legacy rows may not have a media source id, so keep index fallback.
if ((downloadedSourceId == null || downloadedSourceId.isEmpty) && downloadedItem.mediaIndex != mediaIndex) {
appLogger.d(
'[VersionTrace] Offline video is version ${downloadedItem.mediaIndex}, '
'but requested version $mediaIndex — skipping offline',
@@ -121,7 +140,12 @@ class PlaybackInitializationService {
String? offlineVideoPath;
if (serverId != null && (preferOffline || client == null) && database != null) {
offlineVideoPath = await getOfflineVideoPath(serverId, metadata.id, mediaIndex: selectedMediaIndex);
offlineVideoPath = await getOfflineVideoPath(
serverId,
metadata.id,
mediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
);
}
// Downloaded playback must not wait on a live server. Cached media info
@@ -74,6 +74,8 @@ class PlaybackProgressTracker {
/// Whether the final stopped progress event was already emitted locally.
bool _stopProgressNotified = false;
Future<void>? _stoppedProgressFuture;
Duration? _lastProgressNotifiedPosition;
static const Duration _progressNotifyDelta = Duration(seconds: 30);
@@ -162,6 +164,20 @@ class PlaybackProgressTracker {
await _sendProgress(state, positionOverride: positionOverride);
}
Future<void> sendStoppedProgressOnce({Duration? positionOverride}) {
final existing = _stoppedProgressFuture;
if (existing != null) return existing;
final future = sendProgress('stopped', positionOverride: positionOverride);
_stoppedProgressFuture = future;
return future;
}
void resumeAfterStoppedReport() {
_stoppedProgressFuture = null;
_stopProgressNotified = false;
_reportSession?.resetAfterStop();
}
Future<void> _sendProgress(String state, {Duration? positionOverride}) async {
Duration? attemptedPosition;
Duration? attemptedDuration;
+16
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
enum _PlaybackReportState { idle, starting, started, stopping, stopFailed, stopped }
@@ -35,12 +36,14 @@ class PlaybackReportSnapshot {
final String state;
final Duration position;
final Duration duration;
final PlaybackReportMetadata report;
final PlaybackStreamSelectionResolver resolveStreamSelection;
const PlaybackReportSnapshot({
required this.state,
required this.position,
required this.duration,
this.report = const PlaybackReportMetadata.live(),
this.resolveStreamSelection = _noStreamSelection,
});
@@ -70,6 +73,18 @@ class PlaybackReportSession {
bool get isIdle => _state == _PlaybackReportState.idle;
bool get isStopped => _state == _PlaybackReportState.stopped;
void resetAfterStop() {
if (_state == _PlaybackReportState.stopped || _state == _PlaybackReportState.stopFailed) {
_state = _PlaybackReportState.idle;
_startSnapshot = null;
_discardPendingProgress();
_pumpFuture = null;
_stopFuture = null;
}
}
bool get _isStoppingOrTerminal =>
_state == _PlaybackReportState.stopping ||
_state == _PlaybackReportState.stopFailed ||
@@ -248,6 +263,7 @@ class PlaybackReportSession {
duration: snapshot.duration,
playSessionId: playSessionId,
mediaSourceId: selection.mediaSourceId,
report: snapshot.report,
);
}
}
@@ -0,0 +1,79 @@
import '../database/app_database.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../models/transcode_quality_preset.dart';
import 'multi_server_manager.dart';
import 'playback_context.dart';
import 'playback_initialization_service.dart';
class PlaybackSourceResolver {
final MultiServerManager serverManager;
final AppDatabase database;
const PlaybackSourceResolver({required this.serverManager, required this.database});
Future<PlaybackContext> resolve({
required MediaItem metadata,
required int selectedMediaIndex,
String? selectedMediaSourceId,
required bool offlineLibraryMode,
required TranscodeQualityPreset qualityPreset,
int? selectedAudioStreamId,
String? sessionIdentifier,
String? transcodeSessionId,
}) async {
final reportingClient = _onlineClient(metadata.serverId);
final service = PlaybackInitializationService(client: reportingClient, database: database);
final result = await service.getPlaybackData(
metadata: metadata,
selectedMediaIndex: selectedMediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferOffline: offlineLibraryMode || qualityPreset.isOriginal,
qualityPreset: qualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
);
final sourceKind = result.usesLocalMedia
? PlaybackSourceKind.localFile
: result.isTranscoding
? PlaybackSourceKind.remoteTranscode
: PlaybackSourceKind.remoteDirect;
final reportingMode = _reportingMode(
sourceKind: sourceKind,
client: reportingClient,
offlineLibraryMode: offlineLibraryMode,
);
final scopeId = reportingClient?.cacheServerId;
return PlaybackContext(
metadata: metadata,
result: result,
sourceKind: sourceKind,
reportingMode: reportingMode,
reportingClient: reportingClient,
clientScopeId: scopeId == metadata.serverId ? null : scopeId,
streamHeaders: result.usesLocalMedia ? null : reportingClient?.streamHeaders,
);
}
MediaServerClient? _onlineClient(String? serverId) {
if (serverId == null || !serverManager.isClientOnline(serverId)) return null;
return serverManager.getClient(serverId);
}
PlaybackReportingMode _reportingMode({
required PlaybackSourceKind sourceKind,
required MediaServerClient? client,
required bool offlineLibraryMode,
}) {
if (client != null) {
return sourceKind == PlaybackSourceKind.localFile
? PlaybackReportingMode.onlineWithOfflineFallback
: PlaybackReportingMode.online;
}
if (sourceKind == PlaybackSourceKind.localFile || offlineLibraryMode) return PlaybackReportingMode.offlineQueue;
return PlaybackReportingMode.disabled;
}
}
+12 -13
View File
@@ -16,6 +16,7 @@ import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../utils/external_ids.dart';
import 'bif_thumbnail_service.dart';
@@ -1610,9 +1611,7 @@ class PlexClient
required int time,
required String state, // 'playing', 'paused', 'stopped', 'buffering'
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
final response = await _http.post(
'/:/timeline',
@@ -1622,9 +1621,9 @@ class PlexClient
'time': time,
'state': state,
'duration': ?duration,
if (offline) 'offline': 1,
if (updatedAt != null) 'updated': updatedAt.millisecondsSinceEpoch ~/ 1000,
if (continuing != null) 'continuing': continuing ? 1 : 0,
if (report.isOfflineReplay) 'offline': 1,
if (report.recordedAt != null) 'updated': report.recordedAt!.millisecondsSinceEpoch ~/ 1000,
if (report.willContinue != null) 'continuing': report.willContinue! ? 1 : 0,
},
);
// Surface non-2xx instead of swallowing — progress is the cornerstone
@@ -3928,17 +3927,13 @@ class PlexClient
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) => updateProgress(
itemId,
time: position.inMilliseconds,
state: 'stopped',
duration: duration?.inMilliseconds,
offline: offline,
updatedAt: updatedAt,
continuing: continuing,
report: report,
);
// ── Downloads ────────────────────────────────────────────────────
@@ -3972,7 +3967,11 @@ class PlexClient
);
}
}
return DownloadResolution(videoUrl: playbackData.videoUrl, externalSubtitles: subtitles);
return DownloadResolution(
videoUrl: playbackData.videoUrl,
mediaSourceId: playbackData.mediaInfo?.mediaSourceId,
externalSubtitles: subtitles,
);
}
@override
+85
View File
@@ -0,0 +1,85 @@
import '../database/app_database.dart';
import '../media/media_item.dart';
import '../utils/watch_state_notifier.dart';
class WatchStateSnapshot {
final bool? isWatched;
final bool hasViewOffsetMs;
final int? viewOffsetMs;
const WatchStateSnapshot({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
bool get isEmpty => isWatched == null && !hasViewOffsetMs;
MediaItem apply(MediaItem item) {
var updated = item;
if (isWatched != null) {
updated = updated.copyWith(viewCount: isWatched! ? 1 : 0);
}
if (hasViewOffsetMs) {
updated = updated.copyWith(viewOffsetMs: viewOffsetMs);
}
return updated;
}
}
class WatchStateResolver {
const WatchStateResolver._();
static WatchStateSnapshot fromEvent(WatchStateEvent event) {
return switch (event.changeType) {
WatchStateChangeType.watched => const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0),
WatchStateChangeType.unwatched => const WatchStateSnapshot(
isWatched: false,
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
WatchStateChangeType.progressUpdate =>
event.isNowWatched == true
? const WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0)
: WatchStateSnapshot(hasViewOffsetMs: event.viewOffset != null, viewOffsetMs: event.viewOffset),
WatchStateChangeType.removedFromContinueWatching => const WatchStateSnapshot(
hasViewOffsetMs: true,
viewOffsetMs: 0,
),
};
}
static WatchStateSnapshot fromActions(Iterable<OfflineWatchProgressItem> actions) {
OfflineWatchProgressItem? latestManual;
OfflineWatchProgressItem? latestProgress;
for (final action in actions) {
if (action.actionType == 'watched' || action.actionType == 'unwatched') {
if (latestManual == null || action.updatedAt > latestManual.updatedAt) latestManual = action;
} else if (action.actionType == 'progress') {
if (latestProgress == null || action.updatedAt > latestProgress.updatedAt) latestProgress = action;
}
}
bool? isWatched;
var hasViewOffsetMs = false;
int? viewOffsetMs;
final progress = latestProgress;
final manual = latestManual;
final progressIsNewest = progress != null && (manual == null || progress.updatedAt >= manual.updatedAt);
if (progress != null && progress.shouldMarkWatched && progressIsNewest) {
isWatched = true;
hasViewOffsetMs = true;
viewOffsetMs = 0;
} else if (manual != null) {
isWatched = manual.actionType == 'watched';
hasViewOffsetMs = true;
viewOffsetMs = 0;
}
if (progress != null && !progress.shouldMarkWatched && progressIsNewest) {
hasViewOffsetMs = true;
viewOffsetMs = progress.viewOffset;
}
return WatchStateSnapshot(isWatched: isWatched, hasViewOffsetMs: hasViewOffsetMs, viewOffsetMs: viewOffsetMs);
}
}
+9 -1
View File
@@ -11,6 +11,7 @@ import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
import '../screens/video_player_screen.dart';
import '../services/external_player_service.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/settings_service.dart';
import 'app_logger.dart';
@@ -62,6 +63,7 @@ Future<bool?> navigateToVideoPlayer(
// Use the manager-routed lookup so Jellyfin items don't trip the
// Plex-only client. The player branches on the returned type internally.
final manager = context.read<MultiServerProvider>().serverManager;
final offlineWatchService = context.read<OfflineWatchSyncService>();
final serverId = metadata.serverId ?? '';
final mediaClient = serverId.isNotEmpty && (!isOffline || manager.isClientOnline(serverId))
? manager.getClient(serverId)
@@ -87,7 +89,11 @@ Future<bool?> navigateToVideoPlayer(
if (isOffline) {
final globalKey = metadata.globalKey;
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
final videoPath = await downloadProvider.getVideoFilePath(
globalKey,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
if (videoPath != null && context.mounted) {
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
launched = await ExternalPlayerService.launch(
@@ -95,6 +101,7 @@ Future<bool?> navigateToVideoPlayer(
videoUrl: videoUrl,
metadata: metadata,
client: mediaClient,
offlineWatchService: offlineWatchService,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
@@ -104,6 +111,7 @@ Future<bool?> navigateToVideoPlayer(
context: context,
metadata: metadata,
client: mediaClient,
offlineWatchService: offlineWatchService,
mediaIndex: mediaIndex,
mediaSourceId: selectedMediaSourceId,
);
+16 -3
View File
@@ -14,6 +14,7 @@ import '../media/media_version.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../services/plex_client.dart';
import '../services/media_list_playback_launcher.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/playlist_items_loader.dart';
import '../services/trackers/tracker_coordinator.dart';
import '../models/transcode_quality_preset.dart';
@@ -1252,19 +1253,31 @@ class MediaContextMenuState extends State<MediaContextMenu> {
// Check if the item is downloaded and use local file path if available
final downloadProvider = Provider.of<DownloadProvider>(context, listen: false);
final offlineWatchService = Provider.of<OfflineWatchSyncService>(context, listen: false);
final client = _getMediaClientForItem();
final globalKey = item.globalKey;
if (downloadProvider.isDownloaded(globalKey)) {
final videoPath = await downloadProvider.getVideoFilePath(globalKey);
if (videoPath != null && context.mounted) {
final videoUrl = videoPath.contains('://') ? videoPath : 'file://$videoPath';
await ExternalPlayerService.launch(context: context, videoUrl: videoUrl);
await ExternalPlayerService.launch(
context: context,
videoUrl: videoUrl,
metadata: item,
client: client,
offlineWatchService: offlineWatchService,
);
return;
}
}
final client = _getMediaClientForItem();
if (!context.mounted) return;
await ExternalPlayerService.launch(context: context, metadata: item, client: client);
await ExternalPlayerService.launch(
context: context,
metadata: item,
client: client,
offlineWatchService: offlineWatchService,
);
}
/// Handle download collection action — opens the same sync/one-time dialog
+2 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/playback_report_metadata.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/live_session_tracker.dart';
@@ -45,9 +46,7 @@ class _FakeJellyfinClient implements JellyfinClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
calls.add('stopped:$itemId:$playSessionId');
}
@@ -10,6 +10,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/playback_report_metadata.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
@@ -79,8 +80,7 @@ class _RecordingMediaClient implements MediaServerClient {
void close() {}
final started = <({String itemId, int positionMs, int? durationMs})>[];
final stopped =
<({String itemId, int positionMs, int? durationMs, bool offline, DateTime? updatedAt, bool? continuing})>[];
final stopped = <({String itemId, int positionMs, int? durationMs, PlaybackReportMetadata report})>[];
final watched = <String>[];
@override
@@ -108,17 +108,13 @@ class _RecordingMediaClient implements MediaServerClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
stopped.add((
itemId: itemId,
positionMs: position.inMilliseconds,
durationMs: duration?.inMilliseconds,
offline: offline,
updatedAt: updatedAt,
continuing: continuing,
report: report,
));
}
@@ -355,8 +351,8 @@ void main() {
expect(client.started.single.positionMs, 50000);
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 50000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.stopped.single.report.isOfflineReplay, isTrue);
expect(client.stopped.single.report.recordedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, isEmpty);
expect(await svc.getPendingSyncCount(), 0);
});
@@ -380,9 +376,9 @@ void main() {
expect(client.stopped, hasLength(1));
expect(client.stopped.single.positionMs, 100000);
expect(client.stopped.single.durationMs, 100000);
expect(client.stopped.single.offline, isTrue);
expect(client.stopped.single.continuing, isFalse);
expect(client.stopped.single.updatedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.stopped.single.report.isOfflineReplay, isTrue);
expect(client.stopped.single.report.willContinue, isFalse);
expect(client.stopped.single.report.recordedAt?.millisecondsSinceEpoch, queued!.updatedAt);
expect(client.watched, ['42']);
expect(await svc.getPendingSyncCount(), 0);
});
@@ -482,7 +478,7 @@ void main() {
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
});
test('returns shouldMarkWatched for a "progress" action', () async {
test('returns true only for progress that crossed the watched threshold', () async {
final (svc: svc, db: db, mgr: mgr) = _makeService();
addTearDown(() async {
svc.dispose();
@@ -490,9 +486,9 @@ void main() {
await db.close();
});
// Below threshold → shouldMarkWatched=false → status=false.
// Below threshold is resume-only, not an explicit unwatched override.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '1', viewOffset: 50, duration: 100);
expect(await svc.getLocalWatchStatus('srv:1'), isFalse);
expect(await svc.getLocalWatchStatus('srv:1'), isNull);
// Above threshold → shouldMarkWatched=true → status=true.
await svc.queueProgressUpdate(serverId: 'srv', itemId: '2', viewOffset: 99, duration: 100);
@@ -771,7 +767,7 @@ void main() {
expect(await svc.getLocalWatchStatus('jf-machine:item-1'), isTrue);
expect(await svc.getLocalViewOffset('jf-machine:item-1'), isNull);
expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isFalse);
expect(await svc.getLocalWatchStatus('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), isNull);
expect(await svc.getLocalViewOffset('jf-machine:item-1', clientScopeId: 'jf-machine/user-a'), 5000);
});
@@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/media/playback_report_metadata.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/offline_watch_sync_service.dart';
@@ -134,9 +135,7 @@ class _FakePlexClient implements PlexClient {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
if (throwOnNextCall != null) {
final err = throwOnNextCall!;
@@ -201,9 +200,7 @@ class _FakePlexClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) {
playbackSessionIds.add(playSessionId);
playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null));
@@ -974,9 +971,7 @@ class _ScrobblePreciseClient implements PlexClient {
required int time,
required String state,
int? duration,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {}
@override
@@ -1011,9 +1006,7 @@ class _ScrobblePreciseClient implements PlexClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {}
@override
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/playback_report_metadata.dart';
import 'package:plezy/services/playback_report_session.dart';
class _RecordingClient implements MediaServerClient {
@@ -47,9 +48,7 @@ class _RecordingClient implements MediaServerClient {
Duration? duration,
String? playSessionId,
String? mediaSourceId,
bool offline = false,
DateTime? updatedAt,
bool? continuing,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId');
if (failNextStop) {