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