refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
This commit is contained in:
@@ -29,7 +29,7 @@ class ConnectionBootstrap {
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
final StorageService storage;
|
||||
@@ -283,16 +283,6 @@ class ConnectionBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final home = await auth.getHomeUsers(accountToken);
|
||||
return home.users;
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
|
||||
+141
-302
@@ -237,197 +237,56 @@ class AppDatabase extends _$AppDatabase {
|
||||
final joinRows = await (select(
|
||||
profileConnections,
|
||||
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get();
|
||||
// Drift's generated serializer is the recovery image's column schema:
|
||||
// `toJson`/`fromJson` use these camelCase keys, so the read and restore
|
||||
// sides can never drift apart when a column is added or renamed.
|
||||
return {
|
||||
'connections': [
|
||||
for (final row in connectionRows)
|
||||
{
|
||||
'id': row.id,
|
||||
'kind': row.kind,
|
||||
'displayName': row.displayName,
|
||||
'configJson': row.configJson,
|
||||
'isDefault': row.isDefault,
|
||||
'createdAt': row.createdAt,
|
||||
'lastAuthenticatedAt': row.lastAuthenticatedAt,
|
||||
},
|
||||
],
|
||||
'profiles': [
|
||||
for (final row in profileRows)
|
||||
{
|
||||
'id': row.id,
|
||||
'kind': row.kind,
|
||||
'displayName': row.displayName,
|
||||
'avatarThumbUrl': row.avatarThumbUrl,
|
||||
'configJson': row.configJson,
|
||||
'sortOrder': row.sortOrder,
|
||||
'createdAt': row.createdAt,
|
||||
'lastUsedAt': row.lastUsedAt,
|
||||
},
|
||||
],
|
||||
'profileConnections': [
|
||||
for (final row in joinRows)
|
||||
{
|
||||
'profileId': row.profileId,
|
||||
'connectionId': row.connectionId,
|
||||
'userToken': row.userToken,
|
||||
'userIdentifier': row.userIdentifier,
|
||||
'isDefault': row.isDefault,
|
||||
'tokenAcquiredAt': row.tokenAcquiredAt,
|
||||
'lastUsedAt': row.lastUsedAt,
|
||||
},
|
||||
],
|
||||
'connections': [for (final row in connectionRows) row.toJson()],
|
||||
'profiles': [for (final row in profileRows) row.toJson()],
|
||||
'profileConnections': [for (final row in joinRows) row.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readPendingRecoveryRows() async {
|
||||
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
|
||||
return {
|
||||
'offlineWatchProgress': [
|
||||
for (final row in rows)
|
||||
{
|
||||
'id': row.id,
|
||||
'profileId': row.profileId,
|
||||
'serverId': row.serverId,
|
||||
'clientScopeId': row.clientScopeId,
|
||||
'ratingKey': row.ratingKey,
|
||||
'globalKey': row.globalKey,
|
||||
'actionType': row.actionType,
|
||||
'viewOffset': row.viewOffset,
|
||||
'duration': row.duration,
|
||||
'shouldMarkWatched': row.shouldMarkWatched,
|
||||
'createdAt': row.createdAt,
|
||||
'updatedAt': row.updatedAt,
|
||||
'syncAttempts': row.syncAttempts,
|
||||
'lastError': row.lastError,
|
||||
},
|
||||
],
|
||||
'offlineWatchProgress': [for (final row in rows) row.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
|
||||
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const {
|
||||
'id',
|
||||
'kind',
|
||||
'displayName',
|
||||
'configJson',
|
||||
'isDefault',
|
||||
'createdAt',
|
||||
'lastAuthenticatedAt',
|
||||
});
|
||||
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const {
|
||||
'id',
|
||||
'kind',
|
||||
'displayName',
|
||||
'avatarThumbUrl',
|
||||
'configJson',
|
||||
'sortOrder',
|
||||
'createdAt',
|
||||
'lastUsedAt',
|
||||
});
|
||||
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const {
|
||||
'profileId',
|
||||
'connectionId',
|
||||
'userToken',
|
||||
'userIdentifier',
|
||||
'isDefault',
|
||||
'tokenAcquiredAt',
|
||||
'lastUsedAt',
|
||||
});
|
||||
final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const {
|
||||
'id',
|
||||
'profileId',
|
||||
'serverId',
|
||||
'clientScopeId',
|
||||
'ratingKey',
|
||||
'globalKey',
|
||||
'actionType',
|
||||
'viewOffset',
|
||||
'duration',
|
||||
'shouldMarkWatched',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'syncAttempts',
|
||||
'lastError',
|
||||
});
|
||||
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson);
|
||||
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson);
|
||||
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson);
|
||||
final pendingRows = _decodeRecoveryRows(
|
||||
snapshot.pending,
|
||||
'offlineWatchProgress',
|
||||
OfflineWatchProgressItem.fromJson,
|
||||
);
|
||||
|
||||
// Recovery images from releases before the credential vault may contain
|
||||
// plaintext secrets. Protect them before they cross into Drift; already
|
||||
// protected values remain byte-identical because vault protection is
|
||||
// idempotent.
|
||||
for (final row in connectionRows) {
|
||||
final kind = _requiredRecoveryValue<String>(row, 'kind');
|
||||
final configJson = _requiredRecoveryValue<String>(row, 'configJson');
|
||||
final decoded = jsonDecode(configJson);
|
||||
for (var index = 0; index < connectionRows.length; index++) {
|
||||
final row = connectionRows[index];
|
||||
final decoded = jsonDecode(row.configJson);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Invalid connection configuration');
|
||||
}
|
||||
if (_containsPlaintextConnectionCredential(kind, decoded)) {
|
||||
row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded));
|
||||
if (_containsPlaintextConnectionCredential(row.kind, decoded)) {
|
||||
connectionRows[index] = row.copyWith(
|
||||
configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final row in joinRows) {
|
||||
final token = _requiredRecoveryValue<String>(row, 'userToken');
|
||||
if (token.isNotEmpty && !CredentialVault.isProtected(token)) {
|
||||
row['userToken'] = await CredentialVault.protect(token);
|
||||
for (var index = 0; index < joinRows.length; index++) {
|
||||
final row = joinRows[index];
|
||||
if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) {
|
||||
joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken));
|
||||
}
|
||||
}
|
||||
|
||||
final connectionCompanions = [
|
||||
for (final row in connectionRows)
|
||||
ConnectionsCompanion(
|
||||
id: Value(_requiredRecoveryValue<String>(row, 'id')),
|
||||
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
|
||||
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
|
||||
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
|
||||
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
|
||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
||||
lastAuthenticatedAt: Value(_nullableRecoveryValue<int>(row, 'lastAuthenticatedAt')),
|
||||
),
|
||||
];
|
||||
final profileCompanions = [
|
||||
for (final row in profileRows)
|
||||
ProfilesCompanion(
|
||||
id: Value(_requiredRecoveryValue<String>(row, 'id')),
|
||||
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
|
||||
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
|
||||
avatarThumbUrl: Value(_nullableRecoveryValue<String>(row, 'avatarThumbUrl')),
|
||||
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
|
||||
sortOrder: Value(_requiredRecoveryValue<int>(row, 'sortOrder')),
|
||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
||||
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
|
||||
),
|
||||
];
|
||||
final joinCompanions = [
|
||||
for (final row in joinRows)
|
||||
ProfileConnectionsCompanion(
|
||||
profileId: Value(_requiredRecoveryValue<String>(row, 'profileId')),
|
||||
connectionId: Value(_requiredRecoveryValue<String>(row, 'connectionId')),
|
||||
userToken: Value(_requiredRecoveryValue<String>(row, 'userToken')),
|
||||
userIdentifier: Value(_requiredRecoveryValue<String>(row, 'userIdentifier')),
|
||||
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
|
||||
tokenAcquiredAt: Value(_nullableRecoveryValue<int>(row, 'tokenAcquiredAt')),
|
||||
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
|
||||
),
|
||||
];
|
||||
final pendingCompanions = [
|
||||
for (final row in pendingRows)
|
||||
OfflineWatchProgressCompanion(
|
||||
id: Value(_requiredRecoveryValue<int>(row, 'id')),
|
||||
profileId: Value(_nullableRecoveryValue<String>(row, 'profileId')),
|
||||
serverId: Value(_requiredRecoveryValue<String>(row, 'serverId')),
|
||||
clientScopeId: Value(_nullableRecoveryValue<String>(row, 'clientScopeId')),
|
||||
ratingKey: Value(_requiredRecoveryValue<String>(row, 'ratingKey')),
|
||||
globalKey: Value(_requiredRecoveryValue<String>(row, 'globalKey')),
|
||||
actionType: Value(_requiredRecoveryValue<String>(row, 'actionType')),
|
||||
viewOffset: Value(_nullableRecoveryValue<int>(row, 'viewOffset')),
|
||||
duration: Value(_nullableRecoveryValue<int>(row, 'duration')),
|
||||
shouldMarkWatched: Value(_requiredRecoveryValue<bool>(row, 'shouldMarkWatched')),
|
||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
||||
updatedAt: Value(_requiredRecoveryValue<int>(row, 'updatedAt')),
|
||||
syncAttempts: Value(_requiredRecoveryValue<int>(row, 'syncAttempts')),
|
||||
lastError: Value(_nullableRecoveryValue<String>(row, 'lastError')),
|
||||
),
|
||||
];
|
||||
|
||||
await transaction(() async {
|
||||
// Recovery completion (the durable marker removal) is deliberately
|
||||
// separate from this transaction. Replace the snapshot-owned rows so a
|
||||
@@ -437,54 +296,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
await delete(profiles).go();
|
||||
await delete(connections).go();
|
||||
await delete(offlineWatchProgress).go();
|
||||
for (final row in connectionCompanions) {
|
||||
await into(connections).insert(row);
|
||||
// `toCompanion(false)` writes every column explicitly, including the
|
||||
// nulls, so a restored row is byte-identical to the captured one rather
|
||||
// than picking up column defaults.
|
||||
for (final row in connectionRows) {
|
||||
await into(connections).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in profileCompanions) {
|
||||
await into(profiles).insert(row);
|
||||
for (final row in profileRows) {
|
||||
await into(profiles).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in joinCompanions) {
|
||||
await into(profileConnections).insert(row);
|
||||
for (final row in joinRows) {
|
||||
await into(profileConnections).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in pendingCompanions) {
|
||||
await into(offlineWatchProgress).insert(row);
|
||||
for (final row in pendingRows) {
|
||||
await into(offlineWatchProgress).insert(row.toCompanion(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _decodeRecoveryRows(
|
||||
static List<T> _decodeRecoveryRows<T extends DataClass>(
|
||||
Map<String, Object?> group,
|
||||
String key,
|
||||
Set<String> expectedKeys,
|
||||
T Function(Map<String, dynamic> json) fromJson,
|
||||
) {
|
||||
final value = group[key];
|
||||
if (value is! List) throw const FormatException('Invalid tvOS database recovery image');
|
||||
if (value is! List) throw _invalidRecoveryImage;
|
||||
return [
|
||||
for (final value in value)
|
||||
if (value is Map<String, Object?> &&
|
||||
value.keys.toSet().containsAll(expectedKeys) &&
|
||||
value.length == expectedKeys.length)
|
||||
value
|
||||
else
|
||||
throw const FormatException('Invalid tvOS database recovery image'),
|
||||
for (final row in value)
|
||||
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
|
||||
];
|
||||
}
|
||||
|
||||
static T _requiredRecoveryValue<T>(Map<String, Object?> row, String key) {
|
||||
final value = row[key];
|
||||
if (!row.containsKey(key) || value is! T) {
|
||||
throw const FormatException('Invalid tvOS database recovery image');
|
||||
/// Reads one row through drift's generated deserializer and rejects anything
|
||||
/// that does not round-trip back to the exact same map. Drift already throws
|
||||
/// on a missing or mistyped required column; the round-trip additionally
|
||||
/// rejects unknown and missing-but-nullable columns, which the serializer
|
||||
/// would otherwise accept silently.
|
||||
static T _decodeRecoveryRow<T extends DataClass>(
|
||||
Map<String, dynamic> row,
|
||||
T Function(Map<String, dynamic> json) fromJson,
|
||||
) {
|
||||
final T decoded;
|
||||
try {
|
||||
decoded = fromJson(row);
|
||||
} catch (_) {
|
||||
throw _invalidRecoveryImage;
|
||||
}
|
||||
return value;
|
||||
if (!mapEquals(decoded.toJson(), row)) throw _invalidRecoveryImage;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
static T? _nullableRecoveryValue<T>(Map<String, Object?> row, String key) {
|
||||
if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image');
|
||||
final value = row[key];
|
||||
if (value == null) return null;
|
||||
if (value is! T) throw const FormatException('Invalid tvOS database recovery image');
|
||||
return value as T;
|
||||
}
|
||||
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||
|
||||
@override
|
||||
int get schemaVersion => 19;
|
||||
@@ -649,89 +511,27 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 17) {
|
||||
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
|
||||
await customStatement('''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| '/~plex-profile/'
|
||||
|| owner.profile_id
|
||||
|| ':'
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
JOIN download_owners AS owner
|
||||
ON owner.global_key = metadata.global_key
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''');
|
||||
await customStatement(
|
||||
_rescopePinnedPlexMetadataStatement(
|
||||
namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'",
|
||||
ownerJoin: '''JOIN download_owners AS owner
|
||||
ON owner.global_key = metadata.global_key''',
|
||||
),
|
||||
);
|
||||
// A direct pre-v14 upgrade has no owners yet: profiles and owner
|
||||
// adoption are bootstrapped only after the database opens. Preserve
|
||||
// those downloads in the neutral Plex transfer namespace so the
|
||||
// first profile can adopt them without inheriting legacy watch data.
|
||||
await customStatement('''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| '/~plex-transfer:'
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM download_owners AS owner
|
||||
WHERE owner.global_key = metadata.global_key
|
||||
)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''');
|
||||
await customStatement(
|
||||
_rescopePinnedPlexMetadataStatement(
|
||||
namespaceExpression: "'/~plex-transfer:'",
|
||||
ownerFilter: '''AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM download_owners AS owner
|
||||
WHERE owner.global_key = metadata.global_key
|
||||
)''',
|
||||
),
|
||||
);
|
||||
|
||||
final transferRows = await customSelect('''
|
||||
SELECT cache_key, data
|
||||
@@ -920,10 +720,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
|
||||
return value == null ? column.isNull() : column.equals(value);
|
||||
}
|
||||
@@ -974,7 +770,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
matchesKey(t) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
(filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
|
||||
}
|
||||
@@ -1083,7 +879,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId) &
|
||||
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
|
||||
t.actionType.equals(OfflineActionType.progress.id),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
@@ -1145,7 +941,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId),
|
||||
_nullableTextPredicate(t.clientScopeId, clientScopeId),
|
||||
))
|
||||
.go();
|
||||
|
||||
@@ -1287,29 +1083,21 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||
Future<void> _writeSyncRule(String globalKey, SyncRulesCompanion values) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values);
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||
}
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
|
||||
}
|
||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
|
||||
|
||||
Future<void> deleteSyncRule(String globalKey) async {
|
||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
@@ -1331,6 +1119,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into
|
||||
/// a scoped cache namespace.
|
||||
///
|
||||
/// The owned and the ownerless branch run the same operation over the same
|
||||
/// `download_metadata_ids` set and differ only in three spots: the expression
|
||||
/// spliced into the new `cache_key` ([namespaceExpression]), an optional join
|
||||
/// that exposes the owning profile ([ownerJoin]), and an optional extra
|
||||
/// predicate that keeps each branch to its own rows ([ownerFilter]).
|
||||
String _rescopePinnedPlexMetadataStatement({
|
||||
required String namespaceExpression,
|
||||
String ownerJoin = '',
|
||||
String ownerFilter = '',
|
||||
}) =>
|
||||
'''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| $namespaceExpression
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
$ownerJoin
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
$ownerFilter
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''';
|
||||
|
||||
Future<File> _resolveProductionDatabaseFile() async {
|
||||
final dbFolder = (Platform.isAndroid || Platform.isIOS)
|
||||
? await getApplicationDocumentsDirectory()
|
||||
|
||||
@@ -271,64 +271,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertDownload({
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
}) async {
|
||||
await customUpdate(
|
||||
'''
|
||||
INSERT INTO downloaded_media (
|
||||
server_id,
|
||||
client_scope_id,
|
||||
rating_key,
|
||||
global_key,
|
||||
type,
|
||||
parent_rating_key,
|
||||
grandparent_rating_key,
|
||||
status,
|
||||
media_index,
|
||||
media_source_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(global_key) DO UPDATE SET
|
||||
server_id = excluded.server_id,
|
||||
client_scope_id = excluded.client_scope_id,
|
||||
rating_key = excluded.rating_key,
|
||||
type = excluded.type,
|
||||
parent_rating_key = excluded.parent_rating_key,
|
||||
grandparent_rating_key = excluded.grandparent_rating_key,
|
||||
status = excluded.status,
|
||||
progress = 0,
|
||||
total_bytes = NULL,
|
||||
downloaded_bytes = 0,
|
||||
error_message = NULL,
|
||||
retry_count = 0,
|
||||
media_index = excluded.media_index,
|
||||
media_source_id = excluded.media_source_id
|
||||
''',
|
||||
variables: [
|
||||
Variable<String>(serverId),
|
||||
Variable<String>(clientScopeId),
|
||||
Variable<String>(ratingKey),
|
||||
Variable<String>(globalKey),
|
||||
Variable<String>(type),
|
||||
Variable<String>(parentRatingKey),
|
||||
Variable<String>(grandparentRatingKey),
|
||||
Variable<int>(status),
|
||||
Variable<int>(mediaIndex),
|
||||
Variable<String>(mediaSourceId),
|
||||
],
|
||||
updates: {downloadedMedia},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addToQueue({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
|
||||
@@ -197,7 +197,7 @@ class ProfileConnections extends Table {
|
||||
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
||||
// persisted in `profiles`), so an FK here would reject every join row
|
||||
// they need. Profile deletion instead cleans up join rows explicitly
|
||||
// (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup)
|
||||
// (ProfileConnectionCleanup.removeAllProfileConnections)
|
||||
// before calling ProfileRegistry.remove.
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'key_event_utils.dart';
|
||||
|
||||
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
|
||||
/// inside a sheet or dialog.
|
||||
///
|
||||
/// The host keeps its list and row widgets; this mixin owns the virtual cursor
|
||||
/// ([focusedIndex] / [focusedColumn]), the move-mode state and the key handler.
|
||||
/// Wire it up by passing [handleReorderKeyEvent] to the list's
|
||||
/// `Focus.onKeyEvent` and by reading [focusedIndex], [focusedColumn] and
|
||||
/// [movingIndex] when building rows.
|
||||
///
|
||||
/// Navigation mode: UP/DOWN move between rows (resetting to column 0),
|
||||
/// LEFT/RIGHT move between the row (column 0) and the trailing action columns
|
||||
/// up to [lastReorderColumn], SELECT on column 0 enters move mode and on any
|
||||
/// other column calls [onReorderColumnActivated].
|
||||
///
|
||||
/// Move mode: UP/DOWN swap the moving row with its neighbour, SELECT confirms
|
||||
/// through [onReorderMoveConfirmed], and BACK restores the order captured when
|
||||
/// move mode was entered. BACK outside move mode dismisses the hosting sheet.
|
||||
/// D-pad keys are consumed at the list boundaries so focus cannot escape.
|
||||
mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
||||
/// Row height assumed by [ensureFocusedVisible] (Material `ListTile` with a
|
||||
/// subtitle) and the list's top padding.
|
||||
static const double _itemHeight = 72.0;
|
||||
static const double _listTopPadding = 8.0;
|
||||
|
||||
/// Row the virtual cursor sits on.
|
||||
int focusedIndex = 0;
|
||||
|
||||
/// Column within [focusedIndex]: 0 is the row itself, 1..[lastReorderColumn]
|
||||
/// are the trailing action buttons.
|
||||
int focusedColumn = 0;
|
||||
|
||||
/// Row being moved, or null when not in move mode.
|
||||
int? movingIndex;
|
||||
|
||||
int? _originalIndex;
|
||||
List<E>? _originalOrder;
|
||||
bool _backKeyDownSeen = false;
|
||||
|
||||
/// The list being reordered. Mutated in place while moving and replaced
|
||||
/// wholesale when a move is cancelled.
|
||||
List<E> get reorderItems;
|
||||
set reorderItems(List<E> value);
|
||||
|
||||
/// Right-most focusable column index (0 when the row has no action buttons).
|
||||
int get lastReorderColumn;
|
||||
|
||||
/// Scrollable holding the rows, or null when the host does not scroll the
|
||||
/// focused row into view.
|
||||
ScrollController? get reorderScrollController;
|
||||
|
||||
/// Called when SELECT confirms a move; [reorderItems] already holds the new
|
||||
/// order.
|
||||
void onReorderMoveConfirmed();
|
||||
|
||||
/// Called when SELECT activates a trailing action column (1 or greater).
|
||||
void onReorderColumnActivated(int column, int index);
|
||||
|
||||
/// Scrolls [focusedIndex] into view, parking it ~25% from the viewport top.
|
||||
void ensureFocusedVisible() {
|
||||
final scrollController = reorderScrollController;
|
||||
if (scrollController == null || !scrollController.hasClients) return;
|
||||
|
||||
final double targetTop = _listTopPadding + (focusedIndex * _itemHeight);
|
||||
final double targetBottom = targetTop + _itemHeight;
|
||||
|
||||
final double viewportTop = scrollController.offset;
|
||||
final double viewportHeight = scrollController.position.viewportDimension;
|
||||
final double viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
// Already fully visible — skip
|
||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||
|
||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||
0.0,
|
||||
scrollController.position.maxScrollExtent,
|
||||
);
|
||||
|
||||
scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
// Track back key down/up pairing. If focus was elsewhere during KeyDown
|
||||
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
|
||||
// KeyUp that would incorrectly pop the dialog. Consume it instead.
|
||||
if (key.isBackKey) {
|
||||
if (event is KeyDownEvent) {
|
||||
_backKeyDownSeen = true;
|
||||
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is KeyUpEvent) {
|
||||
_backKeyDownSeen = false;
|
||||
}
|
||||
}
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (movingIndex != null) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
final originalOrder = _originalOrder;
|
||||
if (originalOrder != null) {
|
||||
reorderItems = List<E>.from(originalOrder);
|
||||
}
|
||||
focusedIndex = _originalIndex ?? 0;
|
||||
movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
} else {
|
||||
OverlaySheetController.popAdaptive(context);
|
||||
}
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
final int? moving = movingIndex;
|
||||
if (moving != null) {
|
||||
// Move mode - arrows reorder the item
|
||||
if (key.isUpKey && moving > 0) {
|
||||
_swapMovingItem(moving, moving - 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && moving < reorderItems.length - 1) {
|
||||
_swapMovingItem(moving, moving + 1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
// Confirm move - apply the reorder
|
||||
onReorderMoveConfirmed();
|
||||
setState(() {
|
||||
movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode
|
||||
if (key.isUpKey && focusedIndex > 0) {
|
||||
setState(() {
|
||||
focusedIndex--;
|
||||
focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && focusedIndex < reorderItems.length - 1) {
|
||||
setState(() {
|
||||
focusedIndex++;
|
||||
focusedColumn = 0; // Reset to row when changing rows
|
||||
});
|
||||
ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey && focusedColumn > 0) {
|
||||
setState(() => focusedColumn--);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && focusedColumn < lastReorderColumn) {
|
||||
setState(() => focusedColumn++);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
if (focusedColumn == 0) {
|
||||
// Enter move mode
|
||||
setState(() {
|
||||
movingIndex = focusedIndex;
|
||||
_originalIndex = focusedIndex;
|
||||
_originalOrder = List<E>.from(reorderItems);
|
||||
});
|
||||
} else {
|
||||
onReorderColumnActivated(focusedColumn, focusedIndex);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
// Block d-pad keys at boundaries so focus doesn't escape the dialog
|
||||
if (key.isDpadDirection) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _swapMovingItem(int from, int to) {
|
||||
setState(() {
|
||||
final item = reorderItems.removeAt(from);
|
||||
reorderItems.insert(to, item);
|
||||
movingIndex = to;
|
||||
focusedIndex = to;
|
||||
});
|
||||
ensureFocusedVisible();
|
||||
}
|
||||
}
|
||||
@@ -32,22 +32,18 @@ class ChipKeyCallbacks {
|
||||
/// This mixin handles:
|
||||
/// - Internal/external FocusNode pattern
|
||||
/// - `_isFocused` state tracking
|
||||
/// - Listener setup in `initState`
|
||||
/// - Listener handoff in `didUpdateWidget`
|
||||
/// - Cleanup in `dispose`
|
||||
/// - Listener setup, handoff and cleanup across the State lifecycle
|
||||
///
|
||||
/// To use this mixin:
|
||||
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
|
||||
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
|
||||
/// 3. Implement [debugLabel] to return a debug label for the internal node
|
||||
/// 4. Call [initFocusNode] in your `initState`
|
||||
/// 5. Call [updateFocusNode] in your `didUpdateWidget`
|
||||
/// 6. Call [disposeFocusNode] in your `dispose`
|
||||
/// 7. Use [focusNode] and [isFocused] in your build method
|
||||
/// 4. Use [focusNode] and [isFocused] in your build method
|
||||
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
final _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
bool _isFocused = false;
|
||||
final _selectLongPress = DpadSelectLongPressController();
|
||||
FocusNode? _boundExternalNode;
|
||||
|
||||
/// Override to return the widget's optional external focus node.
|
||||
FocusNode? get widgetFocusNode;
|
||||
@@ -61,22 +57,30 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Whether this widget is currently focused.
|
||||
bool get isFocused => _isFocused;
|
||||
|
||||
/// Call this in your `initState` to set up the focus listener.
|
||||
void initFocusNode() {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindFocusNode();
|
||||
}
|
||||
|
||||
/// Call this in your `didUpdateWidget` with the old widget's focusNode.
|
||||
void updateFocusNode(FocusNode? oldFocusNode) {
|
||||
if (oldFocusNode != widgetFocusNode) {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
@override
|
||||
void didUpdateWidget(T oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_boundExternalNode != widgetFocusNode) {
|
||||
_bindFocusNode();
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this in your `dispose` to clean up the focus listener.
|
||||
void disposeFocusNode() {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNodeBinding.dispose();
|
||||
_selectLongPress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _bindFocusNode() {
|
||||
_boundExternalNode = widgetFocusNode;
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
|
||||
+15
-34
@@ -24,6 +24,7 @@ import 'profiles/profile.dart';
|
||||
import 'profiles/profile_connection_cleanup.dart';
|
||||
import 'profiles/profile_connection_registry.dart';
|
||||
import 'profiles/profile_registry.dart';
|
||||
import 'profiles/profile_selection_policy.dart';
|
||||
import 'mixins/mounted_set_state_mixin.dart';
|
||||
import 'theme/mono_theme.dart';
|
||||
import 'profiles/plex_home_service.dart';
|
||||
@@ -1069,8 +1070,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
pinPrompt: _rootPinPrompt,
|
||||
shouldDeferInitialBind: (_) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
|
||||
activeProfile.hasMultipleProfiles;
|
||||
return activeProfile.requiresSelectionOnOpen(settings);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -1211,7 +1211,7 @@ class _AppShell extends StatelessWidget {
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorKey: rootNavigatorKey,
|
||||
navigatorObservers: [BackKeySuppressorObserver()],
|
||||
home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
||||
home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
||||
// Siri Remote select + gamepad A report as
|
||||
// LogicalKeyboardKey.{select,gameButtonA} which aren't
|
||||
// in Flutter's default shortcut set — Material-level
|
||||
@@ -1298,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
|
||||
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||
}
|
||||
|
||||
class OrientationAwareSetup extends StatefulWidget {
|
||||
const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome});
|
||||
|
||||
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
|
||||
|
||||
@override
|
||||
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
|
||||
}
|
||||
|
||||
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_setOrientationPreferences();
|
||||
}
|
||||
|
||||
void _setOrientationPreferences() {
|
||||
OrientationHelper.restoreDefaultOrientations(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome);
|
||||
}
|
||||
}
|
||||
|
||||
class SetupScreen extends StatefulWidget {
|
||||
const SetupScreen({
|
||||
super.key,
|
||||
@@ -1354,6 +1328,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// The app's first screen: undo any orientation lock a previous run's
|
||||
// full-screen player left behind, and re-apply it whenever the form
|
||||
// factor signals (Theme.platform / MediaQuery size) change.
|
||||
OrientationHelper.restoreDefaultOrientations(context);
|
||||
}
|
||||
|
||||
void _setStatus(String message) {
|
||||
setStateIfMounted(() => _statusMessage = message);
|
||||
}
|
||||
@@ -1416,12 +1399,12 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
profileRegistry: profileRegistry,
|
||||
);
|
||||
await bootstrap.run();
|
||||
final pruned = await pruneUnreferencedJellyfinConnections(
|
||||
final pruned = await ProfileConnectionCleanup(
|
||||
profileConnections: profileConnections,
|
||||
connections: connRegistry,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
).pruneUnreferencedJellyfinConnections();
|
||||
if (pruned > 0) {
|
||||
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
|
||||
}
|
||||
@@ -1559,9 +1542,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||
final requireOnOpen =
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
||||
final shouldPrompt = hasNoActive || requireOnOpen;
|
||||
final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings);
|
||||
|
||||
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
||||
if (shouldPrompt) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ignore_for_file: invalid_annotation_target
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../utils/media_server_http_client.dart' show AbortController;
|
||||
import 'media_kind.dart';
|
||||
|
||||
part 'library_query.freezed.dart';
|
||||
@@ -84,3 +85,33 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request
|
||||
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
||||
return offset + itemCount + (fullPage ? 1 : 0);
|
||||
}
|
||||
|
||||
/// Walk every page of a paginated endpoint and concatenate the results.
|
||||
///
|
||||
/// [fetchPage] receives a zero-based offset and [pageSize] and is called until
|
||||
/// a page comes back empty, the accumulated count reaches the page's
|
||||
/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes
|
||||
/// back shorter than [pageSize]. The short-page break is for backends whose
|
||||
/// total is unreliable; leave it off when the total is authoritative.
|
||||
///
|
||||
/// [abort] is checked before and after every request. Errors propagate.
|
||||
Future<List<T>> drainPages<T>(
|
||||
Future<LibraryPage<T>> Function(int start, int size) fetchPage, {
|
||||
required int pageSize,
|
||||
AbortController? abort,
|
||||
bool stopOnShortPage = false,
|
||||
}) async {
|
||||
final all = <T>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
abort?.throwIfAborted();
|
||||
final page = await fetchPage(start, pageSize);
|
||||
abort?.throwIfAborted();
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
if (stopOnShortPage && page.items.length < pageSize) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -212,55 +212,35 @@ class ServerCapabilities {
|
||||
audioTranscoding: true,
|
||||
);
|
||||
|
||||
ServerCapabilities copyWith({
|
||||
bool? serverSidePlayQueue,
|
||||
bool? serverSidePlaylists,
|
||||
bool? liveTv,
|
||||
bool? liveTvDvr,
|
||||
bool? subtitleSearch,
|
||||
bool? videoTranscoding,
|
||||
bool? serverSideSync,
|
||||
bool? richHubs,
|
||||
bool? numericUserRating,
|
||||
bool? userFavorites,
|
||||
bool? continueWatchingRemoval,
|
||||
bool? externalSubtitleSearch,
|
||||
bool? trackPreferencePersistence,
|
||||
bool? endpointFailover,
|
||||
bool? offlineWatchQueue,
|
||||
bool? discordRpc,
|
||||
bool? richMetadataEdit,
|
||||
AlphaBarMode? alphaBar,
|
||||
bool? scrubThumbnails,
|
||||
bool? folderGrouping,
|
||||
bool? lyrics,
|
||||
bool? instantMix,
|
||||
bool? audioTranscoding,
|
||||
}) {
|
||||
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||||
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
|
||||
/// only override this type needs. Widen the parameter list if another flag
|
||||
/// ever becomes a runtime probe.
|
||||
ServerCapabilities copyWith({bool? videoTranscoding}) {
|
||||
return ServerCapabilities(
|
||||
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
|
||||
serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists,
|
||||
liveTv: liveTv ?? this.liveTv,
|
||||
liveTvDvr: liveTvDvr ?? this.liveTvDvr,
|
||||
subtitleSearch: subtitleSearch ?? this.subtitleSearch,
|
||||
serverSidePlayQueue: serverSidePlayQueue,
|
||||
serverSidePlaylists: serverSidePlaylists,
|
||||
liveTv: liveTv,
|
||||
liveTvDvr: liveTvDvr,
|
||||
subtitleSearch: subtitleSearch,
|
||||
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
||||
serverSideSync: serverSideSync ?? this.serverSideSync,
|
||||
richHubs: richHubs ?? this.richHubs,
|
||||
numericUserRating: numericUserRating ?? this.numericUserRating,
|
||||
userFavorites: userFavorites ?? this.userFavorites,
|
||||
continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval,
|
||||
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch,
|
||||
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence,
|
||||
endpointFailover: endpointFailover ?? this.endpointFailover,
|
||||
offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue,
|
||||
discordRpc: discordRpc ?? this.discordRpc,
|
||||
richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit,
|
||||
alphaBar: alphaBar ?? this.alphaBar,
|
||||
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
|
||||
folderGrouping: folderGrouping ?? this.folderGrouping,
|
||||
lyrics: lyrics ?? this.lyrics,
|
||||
instantMix: instantMix ?? this.instantMix,
|
||||
audioTranscoding: audioTranscoding ?? this.audioTranscoding,
|
||||
serverSideSync: serverSideSync,
|
||||
richHubs: richHubs,
|
||||
numericUserRating: numericUserRating,
|
||||
userFavorites: userFavorites,
|
||||
continueWatchingRemoval: continueWatchingRemoval,
|
||||
externalSubtitleSearch: externalSubtitleSearch,
|
||||
trackPreferencePersistence: trackPreferencePersistence,
|
||||
endpointFailover: endpointFailover,
|
||||
offlineWatchQueue: offlineWatchQueue,
|
||||
discordRpc: discordRpc,
|
||||
richMetadataEdit: richMetadataEdit,
|
||||
alphaBar: alphaBar,
|
||||
scrubThumbnails: scrubThumbnails,
|
||||
folderGrouping: folderGrouping,
|
||||
lyrics: lyrics,
|
||||
instantMix: instantMix,
|
||||
audioTranscoding: audioTranscoding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/jellyfin_client.dart';
|
||||
import '../utils/jellyfin_time.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import 'metadata_edit_models.dart';
|
||||
|
||||
class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
@@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||
final kind = draft.sourceItem.kind;
|
||||
return [
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
||||
MetadataEditSection(
|
||||
id: 'basic',
|
||||
title: t.metadataEdit.basicInfo,
|
||||
fields: metadataBasicFields(kind, studioType: MetadataEditFieldType.stringList),
|
||||
),
|
||||
if (_tagFields(kind).isNotEmpty)
|
||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||
@@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final dto = Map<String, dynamic>.from(raw);
|
||||
|
||||
dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
|
||||
dto['Tags'] = _stringList(dto['Tags']);
|
||||
dto['Genres'] = _stringList(dto['Genres']);
|
||||
dto['Tags'] = metadataStringList(dto['Tags']);
|
||||
dto['Genres'] = metadataStringList(dto['Genres']);
|
||||
dto['People'] = _mapList(dto['People']);
|
||||
dto['Studios'] = _mapList(dto['Studios']);
|
||||
dto['LockedFields'] = _stringList(dto['LockedFields']);
|
||||
dto['LockedFields'] = metadataStringList(dto['LockedFields']);
|
||||
dto['LockData'] = dto['LockData'] == true;
|
||||
dto.remove('Trickplay');
|
||||
|
||||
@@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final value = draft.value<String>('originallyAvailableAt') ?? '';
|
||||
dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']);
|
||||
}
|
||||
if (_fieldChanged(draft, 'studio')) {
|
||||
if (_listFieldChanged(draft, 'studio')) {
|
||||
dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio']));
|
||||
}
|
||||
if (draft.fieldChanged('tagline')) {
|
||||
final tagline = metadataEmptyToNull(draft.value<String>('tagline'));
|
||||
final existing = _stringList(dto['Taglines']);
|
||||
final existing = metadataStringList(dto['Taglines']);
|
||||
dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)];
|
||||
}
|
||||
if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
||||
if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
||||
if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
||||
if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
||||
if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
||||
if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
||||
|
||||
var peopleChanged = false;
|
||||
var people = _mapList(dto['People']);
|
||||
if (_fieldChanged(draft, 'director')) {
|
||||
if (_listFieldChanged(draft, 'director')) {
|
||||
people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
if (_fieldChanged(draft, 'writer')) {
|
||||
if (_listFieldChanged(draft, 'writer')) {
|
||||
people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
if (_fieldChanged(draft, 'producer')) {
|
||||
if (_listFieldChanged(draft, 'producer')) {
|
||||
people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
@@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||
final imageType = field.artwork?.key;
|
||||
@@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
? metadataFirstString(raw['Taglines'])
|
||||
: item.tagline ?? '';
|
||||
values['summary'] = raw['Overview'] as String? ?? item.summary ?? '';
|
||||
values['genre'] = _stringList(raw['Genres']);
|
||||
values['genre'] = metadataStringList(raw['Genres']);
|
||||
values['director'] = _peopleByType(raw['People'], 'Director');
|
||||
values['writer'] = _peopleByType(raw['People'], 'Writer');
|
||||
values['producer'] = _peopleByType(raw['People'], 'Producer');
|
||||
values['country'] = _stringList(raw['ProductionLocations']);
|
||||
values['label'] = _stringList(raw['Tags']);
|
||||
values['country'] = metadataStringList(raw['ProductionLocations']);
|
||||
values['label'] = metadataStringList(raw['Tags']);
|
||||
}
|
||||
|
||||
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
|
||||
@@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
values['artwork:Logo'] = item.clearLogoPath;
|
||||
}
|
||||
|
||||
List<MetadataEditField> _basicFields(MediaKind kind) {
|
||||
return [
|
||||
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(
|
||||
id: 'originallyAvailableAt',
|
||||
label: t.metadataEdit.releaseDate,
|
||||
type: MetadataEditFieldType.date,
|
||||
),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.stringList),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
||||
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
||||
];
|
||||
}
|
||||
|
||||
List<MetadataEditField> _tagFields(MediaKind kind) {
|
||||
MetadataEditField tag(String id, String label) =>
|
||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||
@@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
||||
final fields = <MetadataEditField>[
|
||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||
kind == MediaKind.episode
|
||||
? _artworkField(
|
||||
'Primary',
|
||||
t.metadataEdit.poster,
|
||||
t.metadataEdit.selectPoster,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.thumb,
|
||||
)
|
||||
: _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||
];
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'Backdrop',
|
||||
t.metadataEdit.background,
|
||||
t.metadataEdit.selectBackground,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.art,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'Logo',
|
||||
t.metadataEdit.logo,
|
||||
t.metadataEdit.selectLogo,
|
||||
80,
|
||||
32,
|
||||
2,
|
||||
2.5,
|
||||
fit: MetadataArtworkFit.contain,
|
||||
imageType: ImageType.logo,
|
||||
),
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
MetadataEditField _artworkField(
|
||||
String key,
|
||||
String label,
|
||||
String title,
|
||||
double width,
|
||||
double height,
|
||||
int columns,
|
||||
double aspectRatio, {
|
||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
||||
ImageType imageType = ImageType.poster,
|
||||
}) {
|
||||
return MetadataEditField(
|
||||
id: 'artwork:$key',
|
||||
label: label,
|
||||
type: MetadataEditFieldType.artwork,
|
||||
saveMode: MetadataEditSaveMode.immediate,
|
||||
artwork: MetadataArtworkConfig(
|
||||
key: key,
|
||||
selectTitle: title,
|
||||
previewWidth: width,
|
||||
previewHeight: height,
|
||||
gridColumns: columns,
|
||||
gridAspectRatio: aspectRatio,
|
||||
fit: fit,
|
||||
imageType: imageType,
|
||||
),
|
||||
);
|
||||
}
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) =>
|
||||
metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo');
|
||||
|
||||
void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
|
||||
if (!draft.fieldChanged(fieldId)) return;
|
||||
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId));
|
||||
}
|
||||
|
||||
bool _fieldChanged(MetadataEditDraft draft, String fieldId) {
|
||||
for (final section in schemaFor(draft)) {
|
||||
for (final field in section.fields) {
|
||||
if (field.id == fieldId) return metadataEditFieldChanged(draft, field);
|
||||
}
|
||||
}
|
||||
return draft.fieldChanged(fieldId);
|
||||
}
|
||||
/// Every id passed here names a `stringList` field, so the comparison is
|
||||
/// order-insensitive regardless of which kind's schema is in play.
|
||||
bool _listFieldChanged(MetadataEditDraft draft, String fieldId) =>
|
||||
!metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]);
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? value) => metadataStringList(value);
|
||||
|
||||
Map<String, String> _stringMap(Object? value) {
|
||||
if (value is! Map) return <String, String>{};
|
||||
return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
@@ -147,7 +148,9 @@ abstract class MetadataEditAdapter {
|
||||
|
||||
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
|
||||
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option);
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||
}
|
||||
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
|
||||
|
||||
@@ -160,6 +163,133 @@ abstract class MetadataEditAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic-info fields shared by every backend; only the studio field type differs.
|
||||
List<MetadataEditField> metadataBasicFields(
|
||||
MediaKind kind, {
|
||||
MetadataEditFieldType studioType = MetadataEditFieldType.text,
|
||||
}) {
|
||||
return [
|
||||
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(
|
||||
id: 'originallyAvailableAt',
|
||||
label: t.metadataEdit.releaseDate,
|
||||
type: MetadataEditFieldType.date,
|
||||
),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
||||
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
||||
];
|
||||
}
|
||||
|
||||
/// Artwork fields shared by every backend; each backend supplies its own artwork
|
||||
/// key names, which kinds carry a logo, and whether square art exists.
|
||||
List<MetadataEditField> metadataArtworkFields(
|
||||
MediaKind kind, {
|
||||
required String posterKey,
|
||||
required String backdropKey,
|
||||
required String logoKey,
|
||||
String? squareKey,
|
||||
Set<MediaKind> logoKinds = const {MediaKind.movie, MediaKind.show},
|
||||
}) {
|
||||
final fields = <MetadataEditField>[
|
||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||
kind == MediaKind.episode
|
||||
? metadataArtworkField(
|
||||
posterKey,
|
||||
t.metadataEdit.poster,
|
||||
t.metadataEdit.selectPoster,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.thumb,
|
||||
)
|
||||
: metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||
];
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||
fields.add(
|
||||
metadataArtworkField(
|
||||
backdropKey,
|
||||
t.metadataEdit.background,
|
||||
t.metadataEdit.selectBackground,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.art,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (logoKinds.contains(kind)) {
|
||||
fields.add(
|
||||
metadataArtworkField(
|
||||
logoKey,
|
||||
t.metadataEdit.logo,
|
||||
t.metadataEdit.selectLogo,
|
||||
80,
|
||||
32,
|
||||
2,
|
||||
2.5,
|
||||
fit: MetadataArtworkFit.contain,
|
||||
imageType: ImageType.logo,
|
||||
),
|
||||
);
|
||||
if (squareKey != null) {
|
||||
fields.add(
|
||||
metadataArtworkField(
|
||||
squareKey,
|
||||
t.metadataEdit.squareArt,
|
||||
t.metadataEdit.selectSquareArt,
|
||||
50,
|
||||
50,
|
||||
3,
|
||||
1,
|
||||
imageType: ImageType.avatar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
MetadataEditField metadataArtworkField(
|
||||
String key,
|
||||
String label,
|
||||
String title,
|
||||
double width,
|
||||
double height,
|
||||
int columns,
|
||||
double aspectRatio, {
|
||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
||||
ImageType imageType = ImageType.poster,
|
||||
}) {
|
||||
return MetadataEditField(
|
||||
id: 'artwork:$key',
|
||||
label: label,
|
||||
type: MetadataEditFieldType.artwork,
|
||||
saveMode: MetadataEditSaveMode.immediate,
|
||||
artwork: MetadataArtworkConfig(
|
||||
key: key,
|
||||
selectTitle: title,
|
||||
previewWidth: width,
|
||||
previewHeight: height,
|
||||
gridColumns: columns,
|
||||
gridAspectRatio: aspectRatio,
|
||||
fit: fit,
|
||||
imageType: imageType,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool metadataEditValueEquals(Object? a, Object? b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a is List && b is List) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import 'metadata_edit_models.dart';
|
||||
|
||||
class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
@@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||
final kind = draft.sourceItem.kind;
|
||||
return [
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)),
|
||||
if (_tagFields(kind).isNotEmpty)
|
||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||
@@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||
final element = field.artwork?.key;
|
||||
@@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
List<MetadataEditField> _basicFields(MediaKind kind) {
|
||||
return [
|
||||
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(
|
||||
id: 'originallyAvailableAt',
|
||||
label: t.metadataEdit.releaseDate,
|
||||
type: MetadataEditFieldType.date,
|
||||
),
|
||||
if (kind != MediaKind.season)
|
||||
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text),
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
||||
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
||||
];
|
||||
}
|
||||
|
||||
List<MetadataEditField> _tagFields(MediaKind kind) {
|
||||
MetadataEditField tag(String id, String label) =>
|
||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||
@@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
||||
final fields = <MetadataEditField>[
|
||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||
kind == MediaKind.episode
|
||||
? _artworkField(
|
||||
'posters',
|
||||
t.metadataEdit.poster,
|
||||
t.metadataEdit.selectPoster,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.thumb,
|
||||
)
|
||||
: _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||
];
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'arts',
|
||||
t.metadataEdit.background,
|
||||
t.metadataEdit.selectBackground,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.art,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'clearLogos',
|
||||
t.metadataEdit.logo,
|
||||
t.metadataEdit.selectLogo,
|
||||
80,
|
||||
32,
|
||||
2,
|
||||
2.5,
|
||||
fit: MetadataArtworkFit.contain,
|
||||
imageType: ImageType.logo,
|
||||
),
|
||||
);
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'squareArts',
|
||||
t.metadataEdit.squareArt,
|
||||
t.metadataEdit.selectSquareArt,
|
||||
50,
|
||||
50,
|
||||
3,
|
||||
1,
|
||||
imageType: ImageType.avatar,
|
||||
),
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
MetadataEditField _artworkField(
|
||||
String key,
|
||||
String label,
|
||||
String title,
|
||||
double width,
|
||||
double height,
|
||||
int columns,
|
||||
double aspectRatio, {
|
||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
||||
ImageType imageType = ImageType.poster,
|
||||
}) {
|
||||
return MetadataEditField(
|
||||
id: 'artwork:$key',
|
||||
label: label,
|
||||
type: MetadataEditFieldType.artwork,
|
||||
saveMode: MetadataEditSaveMode.immediate,
|
||||
artwork: MetadataArtworkConfig(
|
||||
key: key,
|
||||
selectTitle: title,
|
||||
previewWidth: width,
|
||||
previewHeight: height,
|
||||
gridColumns: columns,
|
||||
gridAspectRatio: aspectRatio,
|
||||
fit: fit,
|
||||
imageType: imageType,
|
||||
),
|
||||
);
|
||||
}
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) => metadataArtworkFields(
|
||||
kind,
|
||||
posterKey: 'posters',
|
||||
backdropKey: 'arts',
|
||||
logoKey: 'clearLogos',
|
||||
squareKey: 'squareArts',
|
||||
logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection},
|
||||
);
|
||||
|
||||
List<MetadataEditField> _advancedFields(MediaKind kind) {
|
||||
final fields = <MetadataEditField>[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import 'event_aware.dart';
|
||||
import 'watch_state_aware.dart';
|
||||
|
||||
/// Mixin for screens that need to react to deletion events.
|
||||
///
|
||||
@@ -74,3 +75,21 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Points [DeletionAware]'s filters at the [WatchStateAware] ones.
|
||||
///
|
||||
/// The usual case: a screen shows the same rows for both event families, so a
|
||||
/// deleted show and a watched show affect exactly the same items. Mix this in
|
||||
/// after both aware mixins instead of re-typing the three getters. A screen
|
||||
/// that genuinely needs a different scope overrides the getter it cares about
|
||||
/// (or skips this mixin entirely).
|
||||
mixin DeletionMirrorsWatchState<T extends StatefulWidget> on WatchStateAware<T>, DeletionAware<T> {
|
||||
@override
|
||||
String? get deletionServerId => watchStateServerId;
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
|
||||
|
||||
@override
|
||||
Set<String>? get deletionIds => watchedIds;
|
||||
}
|
||||
|
||||
@@ -104,43 +104,6 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
return (page: result, applied: true);
|
||||
}
|
||||
|
||||
/// Shared initial-load transaction for paginated consumers.
|
||||
///
|
||||
/// Owns reset, stale-result rejection, mounted checks, and error-state
|
||||
/// application. Callers supply only their view fields, logging, and
|
||||
/// post-success behavior.
|
||||
Future<bool> loadInitialPaginatedItems({
|
||||
required int pageSize,
|
||||
required VoidCallback resetViewState,
|
||||
required void Function(List<T> items) applyLoadedItems,
|
||||
required void Function(Object error, StackTrace stackTrace) applyError,
|
||||
void Function(int loadedCount, int totalCount)? onLoaded,
|
||||
void Function(Object error, StackTrace stackTrace)? onError,
|
||||
}) async {
|
||||
setState(() {
|
||||
resetViewState();
|
||||
resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
final initialPage = await loadInitialPageWithStatus(pageSize);
|
||||
if (!initialPage.applied || !mounted) return false;
|
||||
|
||||
setState(() {
|
||||
applyLoadedItems(loadedItems.values.toList());
|
||||
});
|
||||
onLoaded?.call(loadedItems.length, totalSize);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
onError?.call(error, stackTrace);
|
||||
if (!mounted) return false;
|
||||
setState(() {
|
||||
applyError(error, stackTrace);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
||||
/// with [buffer] extra indices on each side. Serialized — only one
|
||||
/// range-fetch runs at a time — and re-checks after each success so a
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import 'item_updatable.dart';
|
||||
import 'paginated_item_loader.dart';
|
||||
|
||||
/// Standard view-state wiring for screens whose body is a single paginated
|
||||
/// list.
|
||||
///
|
||||
/// [PaginatedItemLoader] owns the sparse `loadedItems` map; the hosts
|
||||
/// (`BaseMediaListDetailScreen`, `BaseLibraryTabState`) additionally expose
|
||||
/// `items` / `isLoading` / `errorMessage` to drive the loading, empty and
|
||||
/// error chrome. This mixin owns the transitions between the two, so a
|
||||
/// screen's `loadItems` supplies only the page size, the error text, and an
|
||||
/// optional post-load hook.
|
||||
mixin StandardPaginatedView<T, W extends StatefulWidget> on PaginatedItemLoader<T, W> {
|
||||
set items(List<T> value);
|
||||
set isLoading(bool value);
|
||||
set errorMessage(String? value);
|
||||
|
||||
/// Initial-load transaction: clears the view state, fetches the first page,
|
||||
/// then publishes either the loaded items or [errorMessageFor]'s text.
|
||||
///
|
||||
/// Stale results — a newer load started, or the screen was disposed — are
|
||||
/// dropped without touching state. [errorMessageFor] runs even when
|
||||
/// unmounted, so screens can log from it; [onLoaded] runs only after a
|
||||
/// successful publish.
|
||||
Future<void> loadStandardPaginatedItems({
|
||||
required int pageSize,
|
||||
required String Function(Object error, StackTrace stackTrace) errorMessageFor,
|
||||
void Function(int loadedCount, int totalCount)? onLoaded,
|
||||
}) async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
final initialPage = await loadInitialPageWithStatus(pageSize);
|
||||
if (!initialPage.applied || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
items = loadedItems.values.toList();
|
||||
isLoading = false;
|
||||
});
|
||||
onLoaded?.call(loadedItems.length, totalSize);
|
||||
} catch (error, stackTrace) {
|
||||
final message = errorMessageFor(error, stackTrace);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
errorMessage = message;
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [ItemUpdatable.updateItemInLists] for screens whose visible list is the
|
||||
/// sparse `loadedItems` map rather than a flat `items` list — searching the
|
||||
/// map is what keeps an item refreshed at a scrolled-in position, past the
|
||||
/// first page.
|
||||
mixin PaginatedItemUpdatable<W extends StatefulWidget> on PaginatedItemLoader<MediaItem, W>, ItemUpdatable<W> {
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.globalKey == sourceGlobalKey) {
|
||||
loadedItems[entry.key] = updatedItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'mixins/multi_server_fields.dart';
|
||||
|
||||
part 'livetv_channel.g.dart';
|
||||
|
||||
@@ -49,7 +48,7 @@ List<LiveTvChannel> filterLiveTvChannelsForFavorites({
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class LiveTvChannel with MultiServerFields {
|
||||
class LiveTvChannel {
|
||||
@JsonKey(readValue: _readChannelKey)
|
||||
final String key;
|
||||
@JsonKey(readValue: _readChannelIdentifier)
|
||||
@@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields {
|
||||
@JsonKey(fromJson: flexibleBool)
|
||||
final bool? drm;
|
||||
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../utils/json_utils.dart';
|
||||
|
||||
part 'media_provider_info.g.dart';
|
||||
|
||||
List<MediaProviderFeature> _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson);
|
||||
|
||||
List<Map<String, dynamic>> _parseRawMaps(Object? raw) => flexibleMapList(raw);
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class MediaProviderInfo {
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? id;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? parentID;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String identifier;
|
||||
final String? providerIdentifier;
|
||||
final String? title;
|
||||
final String? types;
|
||||
final String? protocols;
|
||||
final String? epgSource;
|
||||
final String? friendlyName;
|
||||
@JsonKey(name: 'Feature', fromJson: _parseFeatures)
|
||||
final List<MediaProviderFeature> features;
|
||||
|
||||
const MediaProviderInfo({
|
||||
this.id,
|
||||
this.parentID,
|
||||
required this.identifier,
|
||||
this.providerIdentifier,
|
||||
this.title,
|
||||
this.types,
|
||||
this.protocols,
|
||||
this.epgSource,
|
||||
this.friendlyName,
|
||||
this.features = const [],
|
||||
});
|
||||
|
||||
factory MediaProviderInfo.fromJson(Map<String, dynamic> json) => _$MediaProviderInfoFromJson(json);
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class MediaProviderFeature {
|
||||
final String? key;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String type;
|
||||
final String? flavor;
|
||||
final String? scrobbleKey;
|
||||
final String? unscrobbleKey;
|
||||
@JsonKey(name: 'Directory', fromJson: _parseRawMaps)
|
||||
final List<Map<String, dynamic>> directories;
|
||||
@JsonKey(name: 'Action', fromJson: _parseRawMaps)
|
||||
final List<Map<String, dynamic>> actions;
|
||||
@JsonKey(name: 'Pivot', fromJson: _parseRawMaps)
|
||||
final List<Map<String, dynamic>> pivots;
|
||||
|
||||
const MediaProviderFeature({
|
||||
this.key,
|
||||
required this.type,
|
||||
this.flavor,
|
||||
this.scrobbleKey,
|
||||
this.unscrobbleKey,
|
||||
this.directories = const [],
|
||||
this.actions = const [],
|
||||
this.pivots = const [],
|
||||
});
|
||||
|
||||
factory MediaProviderFeature.fromJson(Map<String, dynamic> json) => _$MediaProviderFeatureFromJson(json);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'media_provider_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> json) =>
|
||||
MediaProviderInfo(
|
||||
id: flexibleInt(json['id']),
|
||||
parentID: flexibleInt(json['parentID']),
|
||||
identifier: json['identifier'] as String? ?? '',
|
||||
providerIdentifier: json['providerIdentifier'] as String?,
|
||||
title: json['title'] as String?,
|
||||
types: json['types'] as String?,
|
||||
protocols: json['protocols'] as String?,
|
||||
epgSource: json['epgSource'] as String?,
|
||||
friendlyName: json['friendlyName'] as String?,
|
||||
features: json['Feature'] == null
|
||||
? const []
|
||||
: _parseFeatures(json['Feature']),
|
||||
);
|
||||
|
||||
MediaProviderFeature _$MediaProviderFeatureFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => MediaProviderFeature(
|
||||
key: json['key'] as String?,
|
||||
type: json['type'] as String? ?? '',
|
||||
flavor: json['flavor'] as String?,
|
||||
scrobbleKey: json['scrobbleKey'] as String?,
|
||||
unscrobbleKey: json['unscrobbleKey'] as String?,
|
||||
directories: json['Directory'] == null
|
||||
? const []
|
||||
: _parseRawMaps(json['Directory']),
|
||||
actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']),
|
||||
pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']),
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
/// Mixin that provides multi-server support fields for models.
|
||||
///
|
||||
/// This mixin adds serverId and serverName fields that are excluded from
|
||||
/// JSON serialization but can be used to track which server an item belongs to.
|
||||
mixin MultiServerFields {
|
||||
/// Server machine identifier (not from API)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
String? get serverId;
|
||||
|
||||
/// Server display name (not from API)
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
String? get serverName;
|
||||
}
|
||||
@@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
|
||||
/// ArtCNN real-time model sizes.
|
||||
enum ArtCNNModel {
|
||||
/// Lightweight real-time model
|
||||
c4f16,
|
||||
c4f16('C4F16'),
|
||||
|
||||
/// Higher-quality real-time model
|
||||
c4f32,
|
||||
c4f32('C4F32');
|
||||
|
||||
const ArtCNNModel(this.label);
|
||||
|
||||
/// Display label for the model.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// ArtCNN luma doubler variants.
|
||||
enum ArtCNNVariant {
|
||||
/// Neutral luma doubler
|
||||
neutral,
|
||||
neutral('Neutral', 'neutral'),
|
||||
|
||||
/// Denoise and soften
|
||||
denoise,
|
||||
denoise('Denoise', 'dn'),
|
||||
|
||||
/// Denoise and sharpen
|
||||
denoiseSharpen,
|
||||
denoiseSharpen('Denoise + Sharpen', 'ds');
|
||||
|
||||
const ArtCNNVariant(this.label, this.slug);
|
||||
|
||||
/// Display label for the variant.
|
||||
final String label;
|
||||
|
||||
/// Stable slug used in built-in preset ids and shader asset keys.
|
||||
final String slug;
|
||||
}
|
||||
|
||||
/// Quality tiers for Anime4K presets
|
||||
@@ -39,22 +52,27 @@ enum Anime4KQuality {
|
||||
/// Anime4K modes that define shader combinations
|
||||
enum Anime4KMode {
|
||||
/// Mode A: Clamp + Restore
|
||||
modeA,
|
||||
modeA('A'),
|
||||
|
||||
/// Mode B: Clamp + Restore + Upscale + Downscale
|
||||
modeB,
|
||||
modeB('B'),
|
||||
|
||||
/// Mode C: Clamp + Upscale + Downscale
|
||||
modeC,
|
||||
modeC('C'),
|
||||
|
||||
/// Mode A+A: Clamp + Restore + Restore
|
||||
modeAA,
|
||||
modeAA('A+A'),
|
||||
|
||||
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
|
||||
modeBB,
|
||||
modeBB('B+B'),
|
||||
|
||||
/// Mode C+A: Clamp + Upscale + Restore + Downscale
|
||||
modeCA,
|
||||
modeCA('C+A');
|
||||
|
||||
const Anime4KMode(this.label);
|
||||
|
||||
/// Display label for the mode.
|
||||
final String label;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -120,93 +138,28 @@ class ShaderPreset {
|
||||
);
|
||||
|
||||
/// Create an ArtCNN preset with the specified model and variant
|
||||
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) {
|
||||
final modelName = _getArtCNNModelName(model);
|
||||
final variantName = _getArtCNNVariantName(variant);
|
||||
final variantId = _getArtCNNVariantId(variant);
|
||||
|
||||
return ShaderPreset(
|
||||
id: 'artcnn_${model.name}_$variantId',
|
||||
name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName',
|
||||
type: ShaderPresetType.artcnn,
|
||||
artcnnConfig: ArtCNNConfig(model: model, variant: variant),
|
||||
);
|
||||
}
|
||||
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset(
|
||||
id: 'artcnn_${model.name}_${variant.slug}',
|
||||
name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}',
|
||||
type: ShaderPresetType.artcnn,
|
||||
artcnnConfig: ArtCNNConfig(model: model, variant: variant),
|
||||
);
|
||||
|
||||
/// Create an Anime4K preset with the specified quality and mode
|
||||
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
|
||||
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
|
||||
final modeName = _getModeName(mode);
|
||||
|
||||
return ShaderPreset(
|
||||
id: 'anime4k_${quality.name}_${mode.name}',
|
||||
name: 'Anime4K $qualityName $modeName',
|
||||
name: 'Anime4K $qualityName ${mode.label}',
|
||||
type: ShaderPresetType.anime4k,
|
||||
anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
|
||||
);
|
||||
}
|
||||
|
||||
static String _getModeName(Anime4KMode mode) {
|
||||
switch (mode) {
|
||||
case Anime4KMode.modeA:
|
||||
return 'A';
|
||||
case Anime4KMode.modeB:
|
||||
return 'B';
|
||||
case Anime4KMode.modeC:
|
||||
return 'C';
|
||||
case Anime4KMode.modeAA:
|
||||
return 'A+A';
|
||||
case Anime4KMode.modeBB:
|
||||
return 'B+B';
|
||||
case Anime4KMode.modeCA:
|
||||
return 'C+A';
|
||||
}
|
||||
}
|
||||
String get modeDisplayName => anime4kConfig?.mode.label ?? '';
|
||||
|
||||
static String _getArtCNNModelName(ArtCNNModel model) {
|
||||
switch (model) {
|
||||
case ArtCNNModel.c4f16:
|
||||
return 'C4F16';
|
||||
case ArtCNNModel.c4f32:
|
||||
return 'C4F32';
|
||||
}
|
||||
}
|
||||
|
||||
static String _getArtCNNVariantName(ArtCNNVariant variant) {
|
||||
switch (variant) {
|
||||
case ArtCNNVariant.neutral:
|
||||
return 'Neutral';
|
||||
case ArtCNNVariant.denoise:
|
||||
return 'Denoise';
|
||||
case ArtCNNVariant.denoiseSharpen:
|
||||
return 'Denoise + Sharpen';
|
||||
}
|
||||
}
|
||||
|
||||
static String _getArtCNNVariantId(ArtCNNVariant variant) {
|
||||
switch (variant) {
|
||||
case ArtCNNVariant.neutral:
|
||||
return 'neutral';
|
||||
case ArtCNNVariant.denoise:
|
||||
return 'dn';
|
||||
case ArtCNNVariant.denoiseSharpen:
|
||||
return 'ds';
|
||||
}
|
||||
}
|
||||
|
||||
String get modeDisplayName {
|
||||
if (anime4kConfig != null) {
|
||||
return _getModeName(anime4kConfig!.mode);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String get artcnnModelDisplayName {
|
||||
if (artcnnConfig != null) {
|
||||
return _getArtCNNModelName(artcnnConfig!.model);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
String get artcnnModelDisplayName => artcnnConfig?.model.label ?? '';
|
||||
|
||||
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
|
||||
none,
|
||||
|
||||
@@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
||||
},
|
||||
};
|
||||
|
||||
/// Build a `POST /sync/history` body that adds this item to history.
|
||||
/// Build a `POST /sync/history[/remove]` body for this item. Both endpoints
|
||||
/// take the same shape; only the removal path ignores [watchedAt].
|
||||
///
|
||||
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
|
||||
/// to a specific point in time; defaults to "now" on Trakt's side.
|
||||
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) => switch (this) {
|
||||
Map<String, dynamic> toHistoryBody({String? watchedAt}) => switch (this) {
|
||||
TraktScrobbleMovieRequest(:final ids) => {
|
||||
'movies': [
|
||||
{'watched_at': ?watchedAt, 'ids': ids.toJson()},
|
||||
@@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/// Build a `POST /sync/history/remove` body that removes this item from history.
|
||||
Map<String, dynamic> toHistoryRemoveBody() => switch (this) {
|
||||
TraktScrobbleMovieRequest(:final ids) => {
|
||||
'movies': [
|
||||
{'ids': ids.toJson()},
|
||||
],
|
||||
},
|
||||
TraktScrobbleEpisodeRequest(:final showIds, :final season, :final number) => {
|
||||
'shows': [
|
||||
{
|
||||
'ids': showIds.toJson(),
|
||||
'seasons': [
|
||||
{
|
||||
'number': season,
|
||||
'episodes': [
|
||||
{'number': number},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class PlexHomeService {
|
||||
this._storage,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
this._refreshInterval = const Duration(hours: 1),
|
||||
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
|
||||
}) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers;
|
||||
|
||||
final ConnectionRegistry _connections;
|
||||
final ProfileConnectionRegistry _profileConnections;
|
||||
@@ -498,13 +498,3 @@ class PlexHomeService {
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final home = await auth.getHomeUsers(accountToken);
|
||||
return home.users;
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,8 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final binder = context.read<ActiveProfileBinder>();
|
||||
// Built before the await: capturing the prompt needs a live context.
|
||||
final promptForPin = dialogPinPrompt(context, profile.displayName);
|
||||
final all = await connections.list();
|
||||
PlexAccountConnection? account;
|
||||
for (final c in all) {
|
||||
@@ -248,10 +250,7 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
||||
account: account,
|
||||
homeUserUuid: homeUuid,
|
||||
requiresPin: true,
|
||||
promptForPin: ({String? errorMessage}) async {
|
||||
if (!context.mounted) return null;
|
||||
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
|
||||
},
|
||||
promptForPin: promptForPin,
|
||||
persistTo: pcRegistry,
|
||||
persistProfileId: profile.id,
|
||||
logLabel: profile.displayName,
|
||||
|
||||
@@ -9,65 +9,6 @@ import 'profile_connection_registry.dart';
|
||||
import 'profile_merge.dart';
|
||||
import 'profile_registry.dart';
|
||||
|
||||
Future<void> removeProfileConnectionAndCleanup({
|
||||
required String profileId,
|
||||
required Connection connection,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
final removedServerIds = _serverIdsForConnection(connection);
|
||||
await profileConnections.remove(profileId, connection.id);
|
||||
await _clearProfileServerPrefsNoLongerReferenced(
|
||||
profileId: profileId,
|
||||
removedServerIds: removedServerIds,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
|
||||
);
|
||||
|
||||
if (connection is JellyfinConnection) {
|
||||
await _removeUnreferencedJellyfinConnection(
|
||||
connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeAllProfileConnectionsAndCleanup({
|
||||
required String profileId,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
final rows = await profileConnections.listForProfile(profileId);
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
for (final row in rows) {
|
||||
final connection = byId[row.connectionId];
|
||||
if (connection == null) {
|
||||
await profileConnections.remove(profileId, row.connectionId);
|
||||
continue;
|
||||
}
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: profileId,
|
||||
connection: connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile ids affected by a Plex account removal. Planning is read-only so
|
||||
/// callers can finish failure-prone cleanup before committing join/account
|
||||
/// deletion.
|
||||
@@ -96,228 +37,201 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
|
||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||
}
|
||||
|
||||
/// Sign out of a Plex account: remove the account [Connection], every join
|
||||
/// row referencing it, and everything owned by its virtual Plex Home
|
||||
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
||||
/// which previously survived as orphans and wedged the session (#1423).
|
||||
///
|
||||
/// Pass a read-only [plannedRemoval] from
|
||||
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
|
||||
/// must finish before this destructive commit. Omitting it preserves the
|
||||
/// atomic add/cancel-account cleanup path.
|
||||
///
|
||||
/// All cleanup is explicit and completes before this returns; correctness
|
||||
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
|
||||
/// runs later and no-ops.
|
||||
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
||||
required PlexAccountConnection account,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
PlexAccountRemoval? plannedRemoval,
|
||||
}) async {
|
||||
final removal =
|
||||
plannedRemoval ??
|
||||
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
|
||||
final removedVirtualProfileIds = removal.removedVirtualProfileIds;
|
||||
final borrowerProfileIds = removal.borrowerProfileIds;
|
||||
final rows = await profileConnections.listAll();
|
||||
// Remove direct join rows first so per-profile pref cleanup observes each
|
||||
// row going away; the FK cascade from the connection delete is then a no-op.
|
||||
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: row.profileId,
|
||||
connection: account,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
}
|
||||
await connections.remove(account.id);
|
||||
await storage.clearPlexHomeUsersCache(account.id);
|
||||
|
||||
// The account's virtual profiles die with the connection; their borrowed
|
||||
// connections and per-profile prefs must go too.
|
||||
for (final profileId in removedVirtualProfileIds) {
|
||||
await removeAllProfileConnectionsAndCleanup(
|
||||
profileId: profileId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await storage.clearProfileLastUsed(profileId);
|
||||
await storage.clearUserScopedPreferencesForProfile(profileId);
|
||||
}
|
||||
|
||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||
}
|
||||
|
||||
/// Where the session should land after a profile or connection removal.
|
||||
enum PostRemovalRoute { signedOut, staySignedIn }
|
||||
|
||||
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
||||
/// accounts are harmless because the connection map is re-read here.
|
||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||
required ProfileRegistry profileRegistry,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
await pruneUnreferencedJellyfinConnections(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
final conns = await connections.list();
|
||||
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||
/// Removal of profile↔connection join rows and everything they leave
|
||||
/// unreferenced, bound to one set of registries. Every flow resolves the same
|
||||
/// instances from the provider tree, so callers construct this once and call
|
||||
/// through it.
|
||||
class ProfileConnectionCleanup {
|
||||
ProfileConnectionCleanup({
|
||||
required this.profileConnections,
|
||||
required this.connections,
|
||||
required this.storage,
|
||||
this.serverManager,
|
||||
});
|
||||
|
||||
final merged = mergeLocalWithPlexHome(
|
||||
locals: await profileRegistry.list(),
|
||||
plexHomeByConnectionId: plexHomeUsers,
|
||||
connectionsById: {for (final c in conns) c.id: c},
|
||||
storage: storage,
|
||||
);
|
||||
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||
}
|
||||
final ProfileConnectionRegistry profileConnections;
|
||||
final ConnectionRegistry connections;
|
||||
final StorageService storage;
|
||||
final MultiServerManager? serverManager;
|
||||
|
||||
Future<int> pruneUnreferencedJellyfinConnections({
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
final all = await connections.list();
|
||||
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
||||
var removed = 0;
|
||||
Future<void> removeProfileConnection({required String profileId, required Connection connection}) async {
|
||||
final removedServerIds = _serverIdsForConnection(connection);
|
||||
await profileConnections.remove(profileId, connection.id);
|
||||
await _clearProfileServerPrefsNoLongerReferenced(
|
||||
profileId: profileId,
|
||||
removedServerIds: removedServerIds,
|
||||
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
|
||||
);
|
||||
|
||||
for (final connection in all.whereType<JellyfinConnection>()) {
|
||||
if (referencedConnectionIds.contains(connection.id)) continue;
|
||||
await _removeJellyfinConnection(
|
||||
connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
if (connection is JellyfinConnection) {
|
||||
await _removeUnreferencedJellyfinConnection(connection);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeAllProfileConnections(String profileId) async {
|
||||
final rows = await profileConnections.listForProfile(profileId);
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
for (final row in rows) {
|
||||
final connection = byId[row.connectionId];
|
||||
if (connection == null) {
|
||||
await profileConnections.remove(profileId, row.connectionId);
|
||||
continue;
|
||||
}
|
||||
await removeProfileConnection(profileId: profileId, connection: connection);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out of a Plex account: remove the account [Connection], every join
|
||||
/// row referencing it, and everything owned by its virtual Plex Home
|
||||
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
||||
/// which previously survived as orphans and wedged the session (#1423).
|
||||
///
|
||||
/// Pass a read-only [plannedRemoval] from
|
||||
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
|
||||
/// must finish before this destructive commit. Omitting it preserves the
|
||||
/// atomic add/cancel-account cleanup path.
|
||||
///
|
||||
/// All cleanup is explicit and completes before this returns; correctness
|
||||
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
|
||||
/// runs later and no-ops.
|
||||
Future<PlexAccountRemoval> removePlexAccountConnection(
|
||||
PlexAccountConnection account, {
|
||||
PlexAccountRemoval? plannedRemoval,
|
||||
}) async {
|
||||
final removal =
|
||||
plannedRemoval ??
|
||||
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
|
||||
final removedVirtualProfileIds = removal.removedVirtualProfileIds;
|
||||
final borrowerProfileIds = removal.borrowerProfileIds;
|
||||
final rows = await profileConnections.listAll();
|
||||
// Remove direct join rows first so per-profile pref cleanup observes each
|
||||
// row going away; the FK cascade from the connection delete is then a no-op.
|
||||
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
||||
await removeProfileConnection(profileId: row.profileId, connection: account);
|
||||
}
|
||||
await connections.remove(account.id);
|
||||
await storage.clearPlexHomeUsersCache(account.id);
|
||||
|
||||
// The account's virtual profiles die with the connection; their borrowed
|
||||
// connections and per-profile prefs must go too.
|
||||
for (final profileId in removedVirtualProfileIds) {
|
||||
await removeAllProfileConnections(profileId);
|
||||
await storage.clearProfileLastUsed(profileId);
|
||||
await storage.clearUserScopedPreferencesForProfile(profileId);
|
||||
}
|
||||
|
||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||
}
|
||||
|
||||
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
||||
/// accounts are harmless because the connection map is re-read here.
|
||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||
required ProfileRegistry profileRegistry,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||
}) async {
|
||||
await pruneUnreferencedJellyfinConnections();
|
||||
final conns = await connections.list();
|
||||
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||
|
||||
final merged = mergeLocalWithPlexHome(
|
||||
locals: await profileRegistry.list(),
|
||||
plexHomeByConnectionId: plexHomeUsers,
|
||||
connectionsById: {for (final c in conns) c.id: c},
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
removed++;
|
||||
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
Future<int> pruneUnreferencedJellyfinConnections() async {
|
||||
final all = await connections.list();
|
||||
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
||||
var removed = 0;
|
||||
|
||||
Future<void> _removeUnreferencedJellyfinConnection(
|
||||
JellyfinConnection connection, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
|
||||
await _removeJellyfinConnection(
|
||||
connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
}
|
||||
for (final connection in all.whereType<JellyfinConnection>()) {
|
||||
if (referencedConnectionIds.contains(connection.id)) continue;
|
||||
await _removeJellyfinConnection(connection);
|
||||
removed++;
|
||||
}
|
||||
|
||||
Future<void> _removeJellyfinConnection(
|
||||
JellyfinConnection connection, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
await connections.remove(connection.id);
|
||||
serverManager?.removeJellyfinConnection(connection);
|
||||
final serverId = ServerId.tryParse(connection.serverMachineId);
|
||||
if (serverId != null &&
|
||||
!await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) {
|
||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||
required String profileId,
|
||||
required Set<ServerId> removedServerIds,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
required bool clearEverywhereWhenUnreferenced,
|
||||
}) async {
|
||||
if (removedServerIds.isEmpty) return;
|
||||
final remainingProfileServerIds = await _serverIdsForProfile(
|
||||
profileId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
);
|
||||
final activeProfileId = storage.getActiveProfileId();
|
||||
Future<void> _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async {
|
||||
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
|
||||
await _removeJellyfinConnection(connection);
|
||||
}
|
||||
|
||||
for (final serverId in removedServerIds) {
|
||||
if (remainingProfileServerIds.contains(serverId)) continue;
|
||||
final serverStillReferenced = await _isServerReferenced(
|
||||
serverId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
);
|
||||
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
|
||||
await storage.clearLibraryPreferencesForServer(
|
||||
serverId,
|
||||
profileId: profileId,
|
||||
includeLegacy: activeProfileId == profileId,
|
||||
);
|
||||
} else {
|
||||
Future<void> _removeJellyfinConnection(JellyfinConnection connection) async {
|
||||
await connections.remove(connection.id);
|
||||
serverManager?.removeJellyfinConnection(connection);
|
||||
final serverId = ServerId.tryParse(connection.serverMachineId);
|
||||
if (serverId != null && !await _isServerReferenced(serverId)) {
|
||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Server ids reachable through this profile's join rows. Narrower than
|
||||
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
||||
/// parent is not counted here, so folding the two together would change which
|
||||
/// per-profile prefs survive an unlink.
|
||||
Future<Set<ServerId>> _serverIdsForProfile(
|
||||
String profileId, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
}) async {
|
||||
final rows = await profileConnections.listForProfile(profileId);
|
||||
if (rows.isEmpty) return const {};
|
||||
Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||
required String profileId,
|
||||
required Set<ServerId> removedServerIds,
|
||||
required bool clearEverywhereWhenUnreferenced,
|
||||
}) async {
|
||||
if (removedServerIds.isEmpty) return;
|
||||
final remainingProfileServerIds = await _serverIdsForProfile(profileId);
|
||||
final activeProfileId = storage.getActiveProfileId();
|
||||
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
return {
|
||||
for (final row in rows)
|
||||
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
|
||||
};
|
||||
}
|
||||
for (final serverId in removedServerIds) {
|
||||
if (remainingProfileServerIds.contains(serverId)) continue;
|
||||
final serverStillReferenced = await _isServerReferenced(serverId);
|
||||
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
|
||||
await storage.clearLibraryPreferencesForServer(
|
||||
serverId,
|
||||
profileId: profileId,
|
||||
includeLegacy: activeProfileId == profileId,
|
||||
);
|
||||
} else {
|
||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isServerReferenced(
|
||||
ServerId serverId, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
}) async {
|
||||
final rows = await profileConnections.listAll();
|
||||
if (rows.isEmpty) return false;
|
||||
/// Server ids reachable through this profile's join rows. Narrower than
|
||||
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
||||
/// parent is not counted here, so folding the two together would change which
|
||||
/// per-profile prefs survive an unlink.
|
||||
Future<Set<ServerId>> _serverIdsForProfile(String profileId) async {
|
||||
final rows = await profileConnections.listForProfile(profileId);
|
||||
if (rows.isEmpty) return const {};
|
||||
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
for (final row in rows) {
|
||||
final connection = byId[row.connectionId];
|
||||
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true;
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
return {
|
||||
for (final row in rows)
|
||||
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _isServerReferenced(ServerId serverId) async {
|
||||
final rows = await profileConnections.listAll();
|
||||
if (rows.isEmpty) return false;
|
||||
|
||||
final all = await connections.list();
|
||||
final byId = {for (final connection in all) connection.id: connection};
|
||||
for (final row in rows) {
|
||||
final connection = byId[row.connectionId];
|
||||
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// [ServerId]-typed for the preference APIs, which drops ids that fail to
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import '../services/settings_service.dart';
|
||||
import 'active_profile_provider.dart';
|
||||
|
||||
extension ProfileSelectionPolicy on ActiveProfileProvider {
|
||||
/// The "ask for a profile every time the app opens" rule: the pref only bites
|
||||
/// when there is more than one profile to pick from.
|
||||
///
|
||||
/// Stated once because the sites must agree — ActiveProfileBinder defers its
|
||||
/// cold-start bind exactly when this holds, and SetupScreen/MainScreen pop the
|
||||
/// picker exactly when it holds. If they drifted, the binder would defer a bind
|
||||
/// that nothing ever prompts for and the user would land on an unbound screen.
|
||||
bool requiresSelectionOnOpen(SettingsService settings) =>
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && hasMultipleProfiles;
|
||||
}
|
||||
@@ -684,57 +684,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
|
||||
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
String? winner;
|
||||
final connected = await _runRemoteConnect(
|
||||
generation: generation,
|
||||
seedConnectingSession: true,
|
||||
rethrowOnFailure: true,
|
||||
join: (peer) async {
|
||||
winner = await peer.joinSessionRacingWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
host.addresses,
|
||||
_authContexts,
|
||||
authContextId: authContext.id,
|
||||
expectedHostClientId: host.clientId,
|
||||
);
|
||||
},
|
||||
onConnected: (peer) {
|
||||
_lastHostAddresses = [winner!];
|
||||
_lastAuthContextId = peer.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? host.clientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
},
|
||||
failureLog: 'CompanionRemote: Failed to connect to host',
|
||||
onFailure: _failRemoteConnectSession,
|
||||
);
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
safeNotifyListeners();
|
||||
|
||||
try {
|
||||
final winner = await candidate.joinSessionRacingWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
host.addresses,
|
||||
_authContexts,
|
||||
authContextId: authContext.id,
|
||||
expectedHostClientId: host.clientId,
|
||||
);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastHostAddresses = [winner];
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
safeNotifyListeners();
|
||||
if (connected) {
|
||||
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace);
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
);
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,48 +729,84 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
|
||||
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
||||
|
||||
await _runRemoteConnect(
|
||||
generation: generation,
|
||||
seedConnectingSession: true,
|
||||
rethrowOnFailure: true,
|
||||
join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts),
|
||||
onConnected: (peer) {
|
||||
_lastAuthContextId = peer.selectedAuthContextId;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? '';
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
},
|
||||
failureLog: 'CompanionRemote: Failed to connect to manual host',
|
||||
onFailure: _failRemoteConnectSession,
|
||||
);
|
||||
}
|
||||
|
||||
void _failRemoteConnectSession(Object error) {
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
);
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
/// Runs the candidate-peer connect lifecycle shared by the discovered/manual
|
||||
/// connect paths and by reconnect attempts: create a candidate, wire its
|
||||
/// listeners, then promote it to [_peerService] or dispose it. The generation
|
||||
/// guards live here so a candidate that lost ownership while joining is
|
||||
/// disposed rather than promoted, in exactly one place. Returns true only
|
||||
/// when the candidate was promoted.
|
||||
Future<bool> _runRemoteConnect({
|
||||
required int generation,
|
||||
required Future<void> Function(CompanionRemotePeerService peer) join,
|
||||
required void Function(CompanionRemotePeerService peer) onConnected,
|
||||
required String failureLog,
|
||||
required void Function(Object error) onFailure,
|
||||
bool seedConnectingSession = false,
|
||||
bool rethrowOnFailure = false,
|
||||
}) async {
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
if (seedConnectingSession) {
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
safeNotifyListeners();
|
||||
if (seedConnectingSession) safeNotifyListeners();
|
||||
|
||||
try {
|
||||
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
||||
await join(candidate);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastAuthContextId = candidate.selectedAuthContextId;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? '';
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
onConnected(candidate);
|
||||
safeNotifyListeners();
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
);
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
appLogger.e(failureLog, error: error, stackTrace: stackTrace);
|
||||
onFailure(error);
|
||||
if (rethrowOnFailure) rethrow;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,47 +992,34 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
}
|
||||
if (generation != _remoteGeneration || isDisposed) return;
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||
final expectedHostClientId = _lastHostClientId ?? '';
|
||||
|
||||
try {
|
||||
await candidate.joinSessionWithContexts(
|
||||
final reconnected = await _runRemoteConnect(
|
||||
generation: generation,
|
||||
join: (peer) => peer.joinSessionWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
hostAddresses.first,
|
||||
_authContexts,
|
||||
authContextId: authContextId,
|
||||
expectedHostClientId: expectedHostClientId,
|
||||
);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||
_reconnectAttempts = 0;
|
||||
safeNotifyListeners();
|
||||
),
|
||||
onConnected: (peer) {
|
||||
_lastAuthContextId = peer.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||
_reconnectAttempts = 0;
|
||||
},
|
||||
failureLog: 'CompanionRemote: Reconnect failed',
|
||||
onFailure: (_) {
|
||||
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
||||
_scheduleReconnect(generation);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (reconnected) {
|
||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace);
|
||||
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
||||
_scheduleReconnect(generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/coalesced_load_coordinator.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/media_event_keys.dart';
|
||||
import '../utils/media_hub_ordering.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import 'hidden_libraries_provider.dart';
|
||||
@@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
/// Watch on-deck items and their parent shows/seasons (an episode's watch
|
||||
/// flip changes what Continue Watching should show for its series).
|
||||
Set<String>? get _watchedIds {
|
||||
final keys = <String>{};
|
||||
for (final item in _onDeck) {
|
||||
keys.add(item.id);
|
||||
if (item.parentId != null) keys.add(item.parentId!);
|
||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
Set<String>? get _watchedIds => hierarchicalEventIds(_onDeck);
|
||||
|
||||
Set<String>? get _watchedGlobalKeys {
|
||||
final keys = <String>{};
|
||||
for (final item in _onDeck) {
|
||||
final serverId = item.serverId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
|
||||
|
||||
void _onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
|
||||
@@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
unawaited(refreshContinueWatching());
|
||||
}
|
||||
|
||||
/// Everything on screen: the Continue Watching row plus every hub row.
|
||||
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
|
||||
|
||||
/// Deletions can affect any visible list, so the filter covers on-deck and
|
||||
/// hub items plus their parents (a deleted season/show takes its visible
|
||||
/// episodes with it).
|
||||
Set<String>? get _deletionIds {
|
||||
final keys = <String>{};
|
||||
void addItem(MediaItem item) {
|
||||
keys.add(item.id);
|
||||
if (item.parentId != null) keys.add(item.parentId!);
|
||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
||||
}
|
||||
Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
|
||||
|
||||
_onDeck.forEach(addItem);
|
||||
for (final hub in _hubs) {
|
||||
hub.items.forEach(addItem);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
Set<String>? get _deletionGlobalKeys {
|
||||
final keys = <String>{};
|
||||
bool addItem(MediaItem item) {
|
||||
final serverId = item.serverId;
|
||||
if (serverId == null) return false;
|
||||
|
||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
||||
return true;
|
||||
}
|
||||
|
||||
for (final item in _onDeck) {
|
||||
if (!addItem(item)) return null;
|
||||
}
|
||||
for (final hub in _hubs) {
|
||||
for (final item in hub.items) {
|
||||
if (!addItem(item)) return null;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
|
||||
|
||||
void _onDeletion(DeletionEvent event) {
|
||||
// On-deck and hubs are server-backed: a download-only deletion leaves the
|
||||
|
||||
@@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
|
||||
hydrated.add(
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: scopedKey,
|
||||
patch: WatchStatePatch.fromSnapshot(snapshot),
|
||||
patch: snapshot,
|
||||
updatedAt: latest.updatedAt,
|
||||
order: latest.id,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import '../media/ids.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
@@ -17,6 +16,7 @@ import '../services/download_manager_service.dart';
|
||||
import '../services/api_cache.dart';
|
||||
import '../services/download_artwork_service.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/downloaded_video_source.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_mode_source.dart';
|
||||
import '../services/watch_state_resolver.dart';
|
||||
@@ -25,7 +25,6 @@ import '../media/media_server_client.dart';
|
||||
import '../services/sync_rule_executor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/downloaded_version_match.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
@@ -925,46 +924,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
appLogger.w('No downloaded item found for globalKey: $globalKey');
|
||||
return null;
|
||||
}
|
||||
if (downloadedItem.status != DownloadStatus.completed.index) {
|
||||
appLogger.w('Download not complete. Status: ${downloadedItem.status}');
|
||||
return null;
|
||||
}
|
||||
if (!downloadedVersionMatches(
|
||||
|
||||
final source = await resolveDownloadedVideoSource(
|
||||
downloadedItem,
|
||||
requestedMediaIndex: mediaIndex,
|
||||
requestedMediaSourceId: mediaSourceId,
|
||||
)) {
|
||||
appLogger.w(
|
||||
'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} '
|
||||
'(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex '
|
||||
'(source ${mediaSourceId?.trim()})',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (downloadedItem.videoFilePath == null) {
|
||||
appLogger.w('Video file path is null for globalKey: $globalKey');
|
||||
return null;
|
||||
}
|
||||
|
||||
final storedPath = downloadedItem.videoFilePath!;
|
||||
final storageService = DownloadStorageService.instance;
|
||||
|
||||
// SAF URIs (content://) are already valid - don't transform them
|
||||
if (storageService.isSafUri(storedPath)) {
|
||||
appLogger.d('Found SAF video path: $storedPath');
|
||||
return storedPath;
|
||||
}
|
||||
|
||||
// Convert stored path (may be relative) to absolute path
|
||||
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
|
||||
|
||||
// Verify file exists
|
||||
final file = File(absolutePath);
|
||||
if (!await file.exists()) {
|
||||
appLogger.w('Offline video file not found: $absolutePath');
|
||||
return null;
|
||||
}
|
||||
return absolutePath;
|
||||
);
|
||||
return source?.path;
|
||||
}
|
||||
|
||||
/// Queue a download for a media item.
|
||||
|
||||
@@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
/// filter to a one-element set when no filter is currently set.
|
||||
void addToVisibleServerIds(ServerId serverId) {
|
||||
final current = _visibleServerIds;
|
||||
if (current == null) {
|
||||
_serverManager.setVisibleServerIds({serverId});
|
||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
||||
safeNotifyListeners();
|
||||
_refreshLiveTvAvailabilitySoon();
|
||||
return;
|
||||
}
|
||||
if (current.contains(serverId)) return;
|
||||
_serverManager.setVisibleServerIds({...current, serverId});
|
||||
if (current != null && current.contains(serverId)) return;
|
||||
_serverManager.setVisibleServerIds({...?current, serverId});
|
||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
||||
safeNotifyListeners();
|
||||
_refreshLiveTvAvailabilitySoon();
|
||||
}
|
||||
|
||||
/// Keep only ids the manager considers visible under the active filter.
|
||||
List<String> _visible(List<String> ids) =>
|
||||
ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
|
||||
|
||||
void _pruneLiveTvServersForVisibility() {
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return;
|
||||
_liveTvServers.removeWhere((s) => !filter.contains(s.serverId));
|
||||
if (_visibleServerIds == null) return;
|
||||
_liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
|
||||
_hasLiveTv = _liveTvServers.isNotEmpty;
|
||||
}
|
||||
|
||||
@@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
|
||||
/// Get all online server IDs (visibility-filtered).
|
||||
List<String> get onlineServerIds {
|
||||
final all = _serverManager.onlineServerIds;
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return all;
|
||||
return all.where(filter.contains).toList();
|
||||
}
|
||||
List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
|
||||
|
||||
/// Get all server IDs (visibility-filtered).
|
||||
List<String> get serverIds {
|
||||
final all = _serverManager.serverIds;
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return all;
|
||||
return all.where(filter.contains).toList();
|
||||
}
|
||||
List<String> get serverIds => _visible(_serverManager.serverIds);
|
||||
|
||||
/// Server ids the active profile is expected to have, including unreachable
|
||||
/// Plex servers that have no live client yet.
|
||||
@@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
|
||||
/// Check if a server is online (and visible under the active profile).
|
||||
bool isServerOnline(ServerId serverId) {
|
||||
final filter = _visibleServerIds;
|
||||
if (filter != null && !filter.contains(serverId)) return false;
|
||||
return _serverManager.isServerOnline(serverId);
|
||||
}
|
||||
bool isServerOnline(ServerId serverId) =>
|
||||
_serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
|
||||
|
||||
/// Get number of online servers
|
||||
int get onlineServerCount => onlineServerIds.length;
|
||||
@@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
}
|
||||
|
||||
final filter = _visibleServerIds;
|
||||
final visibleLiveTvServers = filter == null
|
||||
? newLiveTvServers
|
||||
: newLiveTvServers.where((s) => filter.contains(s.serverId)).toList();
|
||||
final visibleLiveTvServers = newLiveTvServers
|
||||
.where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
|
||||
.toList();
|
||||
|
||||
final hadLiveTv = _hasLiveTv;
|
||||
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
||||
|
||||
@@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
|
||||
@immutable
|
||||
class WatchStatePatch {
|
||||
final bool? isWatched;
|
||||
final bool hasViewOffsetMs;
|
||||
final int? viewOffsetMs;
|
||||
|
||||
const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
|
||||
|
||||
factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch(
|
||||
isWatched: snapshot.isWatched,
|
||||
hasViewOffsetMs: snapshot.hasViewOffsetMs,
|
||||
viewOffsetMs: snapshot.viewOffsetMs,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is WatchStatePatch &&
|
||||
other.isWatched == isWatched &&
|
||||
other.hasViewOffsetMs == hasViewOffsetMs &&
|
||||
other.viewOffsetMs == viewOffsetMs;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class HydratedWatchStatePatch {
|
||||
final String globalKey;
|
||||
final WatchStatePatch patch;
|
||||
final WatchStateSnapshot patch;
|
||||
final int updatedAt;
|
||||
final int order;
|
||||
|
||||
@@ -53,7 +27,7 @@ class HydratedWatchStatePatch {
|
||||
}
|
||||
|
||||
class _WatchStatePatchEntry {
|
||||
final WatchStatePatch patch;
|
||||
final WatchStateSnapshot patch;
|
||||
final int updatedAt;
|
||||
final int sequence;
|
||||
final bool isSessionEvent;
|
||||
@@ -123,9 +97,9 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return _exactEntryFor(globalKey);
|
||||
}
|
||||
|
||||
WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
||||
WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
||||
|
||||
WatchStatePatch? patchForItem(MediaItem item) {
|
||||
WatchStateSnapshot? patchForItem(MediaItem item) {
|
||||
var best = _entryFor(item.globalKey);
|
||||
if (item.parentChain.isNotEmpty) {
|
||||
final serverId = serverIdOrNull(item.serverId);
|
||||
@@ -147,14 +121,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return [for (final item in items) apply(item)];
|
||||
}
|
||||
|
||||
static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) {
|
||||
if (patch == null) return item;
|
||||
return WatchStateSnapshot(
|
||||
isWatched: patch.isWatched,
|
||||
hasViewOffsetMs: patch.hasViewOffsetMs,
|
||||
viewOffsetMs: patch.viewOffsetMs,
|
||||
).apply(item);
|
||||
}
|
||||
static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item);
|
||||
|
||||
void setActiveProfileId(String? profileId) {
|
||||
if (_activeProfileId == profileId) return;
|
||||
@@ -216,7 +183,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
|
||||
: event.globalKey;
|
||||
_patches[key] = _WatchStatePatchEntry(
|
||||
WatchStatePatch.fromSnapshot(snapshot),
|
||||
snapshot,
|
||||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
sequence: ++_sequence,
|
||||
isSessionEvent: true,
|
||||
@@ -240,7 +207,7 @@ extension WatchStateResolution on BuildContext {
|
||||
/// ancestor). Use in `build`.
|
||||
MediaItem withFreshWatchState(MediaItem item) {
|
||||
try {
|
||||
final patch = select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(item));
|
||||
final patch = select<WatchStateStore, WatchStateSnapshot?>((store) => store.patchForItem(item));
|
||||
return WatchStateStore.applyPatch(item, patch);
|
||||
} on ProviderNotFoundException {
|
||||
return item;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../mixins/standard_paginated_view.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
@@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
with
|
||||
GridFocusNodeMixin<ActorMediaScreen>,
|
||||
FocusableDetailScreenMixin<ActorMediaScreen>,
|
||||
PaginatedItemLoader<MediaItem, ActorMediaScreen> {
|
||||
PaginatedItemLoader<MediaItem, ActorMediaScreen>,
|
||||
PaginatedItemUpdatable<ActorMediaScreen>,
|
||||
StandardPaginatedView<MediaItem, ActorMediaScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
@@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.globalKey == sourceGlobalKey) {
|
||||
loadedItems[entry.key] = updatedItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
await loadInitialPaginatedItems(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, _) {
|
||||
errorMessage = t.messages.errorLoading(error: error.toString());
|
||||
isLoading = false;
|
||||
errorMessageFor: (error, stackTrace) {
|
||||
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
||||
return t.messages.errorLoading(error: error.toString());
|
||||
},
|
||||
onLoaded: (loadedCount, totalCount) {
|
||||
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/profile_connection_registry.dart';
|
||||
import '../profiles/profile_selection_policy.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
@@ -196,8 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
activeProfile: activeProfiles.active,
|
||||
hasProfiles: activeProfiles.profiles.isNotEmpty,
|
||||
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
|
||||
requireProfileSelectionOnOpen:
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
|
||||
requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
|
||||
);
|
||||
if (promptHandled) {
|
||||
final selected = await Navigator.of(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
@@ -41,19 +42,34 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
||||
/// Optional icon to show when list is empty
|
||||
IconData? get emptyIcon => null;
|
||||
|
||||
/// Server the displayed item was tagged with, if any.
|
||||
String? get _mediaItemServerId => switch (mediaItem) {
|
||||
MediaItem(:final serverId) => serverId,
|
||||
MediaPlaylist(:final serverId) => serverId,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Sync-rule global key for the displayed collection/playlist, keyed to the
|
||||
/// item's own server when it has one and to the resolved client's otherwise.
|
||||
String get syncRuleKey {
|
||||
final client = mediaClient;
|
||||
final id = switch (mediaItem) {
|
||||
MediaItem(:final id) => id,
|
||||
MediaPlaylist(:final id) => id,
|
||||
_ => '',
|
||||
};
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
client,
|
||||
id,
|
||||
serverId: ServerId(_mediaItemServerId ?? client.serverId),
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveMediaItemServerId() {
|
||||
final item = mediaItem;
|
||||
String? serverId;
|
||||
if (item is MediaItem) {
|
||||
serverId = item.serverId;
|
||||
} else if (item is MediaPlaylist) {
|
||||
serverId = item.serverId;
|
||||
}
|
||||
if (serverId == null) {
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
||||
}
|
||||
return serverId;
|
||||
final serverId = _mediaItemServerId;
|
||||
if (serverId != null) return serverId;
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
return multiServerProvider.onlineServerIds.firstOrNull;
|
||||
}
|
||||
|
||||
MediaServerClient _getMediaClientForMediaItem() {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../media/ids.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../mixins/standard_paginated_view.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/error_message_utils.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
@@ -35,7 +34,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
with
|
||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen> {
|
||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen>,
|
||||
PaginatedItemUpdatable<CollectionDetailScreen>,
|
||||
StandardPaginatedView<MediaItem, CollectionDetailScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
@@ -75,49 +76,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
// Search [loadedItems] (not the flat [items] snapshot, which only has
|
||||
// the first page) so refreshing an item at a scrolled-in position updates
|
||||
// the grid in place.
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.globalKey == sourceGlobalKey) {
|
||||
loadedItems[entry.key] = updatedItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
String? loadErrorMessage;
|
||||
await loadInitialPaginatedItems(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection);
|
||||
isLoading = false;
|
||||
},
|
||||
errorMessageFor: (error, stackTrace) =>
|
||||
localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection),
|
||||
onLoaded: (loadedCount, totalCount) {
|
||||
appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<FocusableAction> getAppBarActions() {
|
||||
final ruleKey = _collectionSyncRuleKey();
|
||||
final ruleKey = syncRuleKey;
|
||||
// Select the specific bool we care about so unrelated DownloadProvider
|
||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||
final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
||||
@@ -127,19 +100,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
||||
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||
],
|
||||
if (!PlatformDetector.isAppleTV())
|
||||
FocusableAction(
|
||||
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
||||
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
||||
onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection,
|
||||
iconColor: hasRule ? Colors.teal : null,
|
||||
),
|
||||
if (!PlatformDetector.isAppleTV() && hasRule)
|
||||
FocusableAction(
|
||||
icon: Symbols.sync_disabled_rounded,
|
||||
tooltip: t.downloads.removeSyncRule,
|
||||
onPressed: _removeCollectionSyncRule,
|
||||
),
|
||||
// Emptiness is handled inside [_downloadCollection], so the download
|
||||
// entry stays visible for empty collections.
|
||||
...buildSyncRuleActions(
|
||||
context,
|
||||
ruleKey: ruleKey,
|
||||
displayTitle: widget.collection.displayTitle,
|
||||
hasRule: hasRule,
|
||||
showDownload: true,
|
||||
onDownload: _downloadCollection,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_rounded,
|
||||
tooltip: t.common.delete,
|
||||
@@ -181,25 +151,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _manageCollectionSyncRule() =>
|
||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _collectionSyncRuleKey());
|
||||
|
||||
Future<void> _removeCollectionSyncRule() => removeSyncRuleAndSnack(
|
||||
context,
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
globalKey: _collectionSyncRuleKey(),
|
||||
displayTitle: widget.collection.displayTitle,
|
||||
);
|
||||
|
||||
String _collectionSyncRuleKey() {
|
||||
final serverId = widget.collection.serverId ?? mediaClient.serverId;
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
mediaClient,
|
||||
widget.collection.id,
|
||||
serverId: ServerId(serverId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteCollection() async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
|
||||
@@ -51,6 +51,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/hub_icons.dart';
|
||||
import '../utils/media_navigation_helper.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
@@ -180,17 +181,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
|
||||
final hubs = <MediaHub>[];
|
||||
if (_onDeck.isNotEmpty) {
|
||||
hubs.add(
|
||||
MediaHub(
|
||||
id: 'continue_watching',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
identifier: '_continue_watching_',
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
),
|
||||
);
|
||||
hubs.add(_continueWatchingHub);
|
||||
}
|
||||
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
||||
_tvBrowseHubsCache = hubs;
|
||||
@@ -198,6 +189,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return hubs;
|
||||
}
|
||||
|
||||
/// The synthesized Continue Watching row, rendered ahead of the backend hubs
|
||||
/// on both the mobile list and the TV rail.
|
||||
MediaHub get _continueWatchingHub => MediaHub(
|
||||
id: 'continue_watching',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
identifier: '_continue_watching_',
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
);
|
||||
|
||||
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
||||
|
||||
void _scrollToTop() {
|
||||
@@ -615,101 +618,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
unawaited(_discover.load());
|
||||
}
|
||||
|
||||
/// Get icon for hub based on its title
|
||||
IconData _getHubIcon(String title) {
|
||||
final lowerTitle = title.toLowerCase();
|
||||
|
||||
// Trending/Popular content
|
||||
if (lowerTitle.contains('trending')) {
|
||||
return Symbols.trending_up_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
|
||||
return Symbols.whatshot_rounded;
|
||||
}
|
||||
|
||||
// Seasonal/Time-based
|
||||
if (lowerTitle.contains('seasonal')) {
|
||||
return Symbols.calendar_month_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
||||
return Symbols.new_releases_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) {
|
||||
return Symbols.schedule_rounded;
|
||||
}
|
||||
|
||||
// Top/Rated content
|
||||
if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) {
|
||||
return Symbols.star_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('top ')) {
|
||||
return Symbols.military_tech_rounded;
|
||||
}
|
||||
|
||||
// Genre-specific
|
||||
if (lowerTitle.contains('thriller')) {
|
||||
return Symbols.warning_amber_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
|
||||
return Symbols.mood_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('action')) {
|
||||
return Symbols.flash_on_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('drama')) {
|
||||
return Symbols.theater_comedy_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('fantasy')) {
|
||||
return Symbols.auto_fix_high_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
|
||||
return Symbols.rocket_launch_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
|
||||
return Symbols.nights_stay_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
|
||||
return Symbols.favorite_border_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
|
||||
return Symbols.explore_rounded;
|
||||
}
|
||||
|
||||
// Watchlist/Playlists
|
||||
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
|
||||
return Symbols.playlist_play_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
|
||||
return Symbols.visibility_off_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
|
||||
return Symbols.visibility_rounded;
|
||||
}
|
||||
|
||||
// Network/Studio
|
||||
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
|
||||
return Symbols.tv_rounded;
|
||||
}
|
||||
|
||||
// Actor/Director
|
||||
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
|
||||
return Symbols.person_rounded;
|
||||
}
|
||||
|
||||
// Year-based (80s, 90s, etc.)
|
||||
if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) {
|
||||
return Symbols.history_rounded;
|
||||
}
|
||||
|
||||
// Rediscover/Start Watching
|
||||
if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) {
|
||||
return Symbols.play_arrow_rounded;
|
||||
}
|
||||
|
||||
// Default icon for other hubs
|
||||
return Symbols.auto_awesome_rounded;
|
||||
}
|
||||
|
||||
/// Whether the loaded hubs span more than one connected server.
|
||||
bool _hubsSpanMultipleServers() {
|
||||
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
|
||||
@@ -1011,6 +919,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
||||
final theme = Theme.of(context);
|
||||
final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub;
|
||||
return Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
@@ -1034,21 +943,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// On Deck / Continue Watching
|
||||
if (_onDeck.isNotEmpty)
|
||||
if (continueWatchingHub != null)
|
||||
SliverToBoxAdapter(
|
||||
child: HubSection(
|
||||
key: _continueWatchingHubKey,
|
||||
hub: MediaHub(
|
||||
id: 'continue_watching',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
identifier: '_continue_watching_',
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
),
|
||||
hub: continueWatchingHub,
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: Symbols.play_circle_rounded,
|
||||
icon: hubIconFor(continueWatchingHub),
|
||||
onRefresh: _discover.updateItem,
|
||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||
isInContinueWatching: true,
|
||||
@@ -1066,7 +967,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null,
|
||||
hub: _hubs[i],
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: _getHubIcon(_hubs[i].title),
|
||||
icon: hubIconFor(_hubs[i]),
|
||||
showServerName: showServerNameOnHubs || hubsSpanMultipleServers,
|
||||
onRefresh: _discover.updateItem,
|
||||
// Hub index is i + 1 if continue watching exists, otherwise i
|
||||
@@ -1152,7 +1053,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
hubs: browseHubs,
|
||||
focusMemory: _hubFocusMemory,
|
||||
showServerName: showServerName,
|
||||
iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title),
|
||||
iconForHub: (hub, _) => hubIconFor(hub),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: _discover.updateItem,
|
||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||
|
||||
@@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/skeleton_media_card.dart';
|
||||
|
||||
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
||||
/// Flutter widget Key.
|
||||
String _idForItem(Object item) {
|
||||
if (item is MediaItem) return item.id;
|
||||
if (item is MediaPlaylist) return item.id;
|
||||
return identityHashCode(item).toString();
|
||||
}
|
||||
|
||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||
///
|
||||
@@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
/// Used by collection, smart playlist, and music artist detail screens.
|
||||
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
|
||||
/// for album grids); null keeps the stock poster geometry.
|
||||
///
|
||||
/// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an
|
||||
/// item, so the skeleton branch is unreachable.
|
||||
Widget buildFocusableGrid({
|
||||
required List<dynamic> items,
|
||||
required List<MediaItem> items,
|
||||
required void Function(MediaItem source) onRefresh,
|
||||
String? collectionId,
|
||||
VoidCallback? onListRefresh,
|
||||
CardShape? shape,
|
||||
}) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
||||
builder: (context) {
|
||||
final svc = SettingsService.instance;
|
||||
final viewMode = svc.read(SettingsService.viewMode);
|
||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
|
||||
|
||||
return MediaCardSliverLayout(
|
||||
viewMode: viewMode,
|
||||
itemCount: items.length,
|
||||
density: libraryDensity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
fullBleedImage: useFullCardLayout,
|
||||
shape: shape,
|
||||
itemBuilder: (context, position) {
|
||||
final index = position.index;
|
||||
final item = items[index];
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(_idForItem(item)),
|
||||
item: item,
|
||||
focusNode: focusNode,
|
||||
semanticValue: _semanticPosition(position),
|
||||
disableScale: position.disableScale,
|
||||
onRefresh: onRefresh,
|
||||
collectionId: collectionId,
|
||||
onListRefresh: onListRefresh,
|
||||
fullBleedImage: useFullCardLayout && position.isGrid,
|
||||
cardShapeOverride: shape,
|
||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
return buildSparseFocusableGrid(
|
||||
totalItems: items.length,
|
||||
itemAt: (index) => items[index],
|
||||
onRefresh: onRefresh,
|
||||
collectionId: collectionId,
|
||||
onListRefresh: onListRefresh,
|
||||
shape: shape,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems]
|
||||
/// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems]
|
||||
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
||||
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
||||
/// the caller can kick off a page fetch containing that index.
|
||||
@@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
onSkeletonVisible?.call(index);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
|
||||
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/loading_indicator_box.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
@@ -493,34 +492,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
|
||||
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
|
||||
|
||||
Widget _buildContinuationStatusSliver() {
|
||||
final exception = _pageLoadError;
|
||||
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: _continuationRetryFocusNode,
|
||||
onPressed: _retryHubContinuation,
|
||||
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
||||
onBack: handleBackFromContent,
|
||||
child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadMoreItems();
|
||||
@@ -633,7 +604,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
},
|
||||
),
|
||||
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
|
||||
_buildContinuationStatusSliver(),
|
||||
ContinuationStatusSliver(
|
||||
error: _pageLoadError,
|
||||
onRetry: _retryHubContinuation,
|
||||
retryFocusNode: _continuationRetryFocusNode,
|
||||
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
||||
onBack: handleBackFromContent,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'state_messages.dart';
|
||||
|
||||
@@ -101,6 +102,55 @@ class SliverEmptyState extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Footer sliver for continuation (append-to-list) pagination: a spinner while
|
||||
/// the next page loads, or the error message with a focusable retry button.
|
||||
class ContinuationStatusSliver extends StatelessWidget {
|
||||
/// Failure from the last page load; null while the page is still loading.
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
final FocusNode retryFocusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const ContinuationStatusSliver({
|
||||
super.key,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.retryFocusNode,
|
||||
this.onNavigateUp,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final exception = error;
|
||||
final message = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: message == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: retryFocusNode,
|
||||
onPressed: onRetry,
|
||||
onNavigateUp: onNavigateUp,
|
||||
onBack: onBack,
|
||||
child: TextButton(onPressed: onRetry, child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that handles loading, error, empty, and content states
|
||||
/// Provides a consistent UI pattern across the app for data-driven screens
|
||||
class ContentStateBuilder<T> extends StatelessWidget {
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../services/jellyfin_sequential_launcher.dart';
|
||||
import '../../services/media_list_playback_launcher.dart';
|
||||
import '../../services/play_queue_launcher.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
@@ -243,42 +244,19 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleFolderPlay(MediaItem folder) async {
|
||||
/// Play (or shuffle) a folder row through the backend's launcher. Built
|
||||
/// here rather than via [MediaListPlaybackLauncher.forItem] because this
|
||||
/// tree is pinned to one server: the Plex client must be the one backing
|
||||
/// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution.
|
||||
Future<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
|
||||
final MediaListPlaybackLauncher launcher;
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
final launcher = JellyfinSequentialLauncher(context: context);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: false);
|
||||
return;
|
||||
launcher = JellyfinSequentialLauncher(context: context);
|
||||
} else {
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
}
|
||||
|
||||
final folderKey = folder.backendFolderKey;
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(
|
||||
folderKey: folderKey,
|
||||
shuffle: false,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleFolderShuffle(MediaItem folder) async {
|
||||
if (folder.backend == MediaBackend.jellyfin) {
|
||||
final launcher = JellyfinSequentialLauncher(context: context);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: true);
|
||||
return;
|
||||
}
|
||||
|
||||
final folderKey = folder.backendFolderKey;
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(
|
||||
folderKey: folderKey,
|
||||
shuffle: true,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
|
||||
}
|
||||
|
||||
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
||||
@@ -400,8 +378,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
serverId: widget.serverId,
|
||||
onExpand: isExpandable ? () => _toggleFolder(item) : null,
|
||||
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
|
||||
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
|
||||
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
|
||||
onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null,
|
||||
onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null,
|
||||
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
|
||||
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
|
||||
@@ -216,6 +216,20 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-load bookkeeping for tabs that replace [loadItems] with their own
|
||||
/// (paginated) fetch: mark the tab loaded, take focus if it's due, and let
|
||||
/// the parent know once the frame carrying the items is in.
|
||||
@protected
|
||||
void markItemsLoaded() {
|
||||
_hasLoadedData = true;
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [focusFirstItem] has a real content target to focus.
|
||||
@protected
|
||||
bool get hasFocusableContent => _items.isNotEmpty;
|
||||
|
||||
@@ -55,6 +55,7 @@ import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../mixins/deletion_aware.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../mixins/standard_paginated_view.dart';
|
||||
import '../../../widgets/card_inflation_budget.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_child_memo.dart';
|
||||
@@ -104,13 +105,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
GridFocusNodeMixin,
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
DeletionMirrorsWatchState,
|
||||
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
|
||||
PaginatedItemUpdatable<LibraryBrowseTab>,
|
||||
SkeletonUpgradeScheduler {
|
||||
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
|
||||
|
||||
@override
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
|
||||
// DeletionMirrorsWatchState points the deletion filters at these three: the
|
||||
// grid shows the same loaded items for both event families.
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@@ -130,22 +132,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys {
|
||||
if (loadedItems.isEmpty) return <String>{};
|
||||
|
||||
final keys = <String>{};
|
||||
for (final item in loadedItems.values) {
|
||||
final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId);
|
||||
if (serverId == null) return null;
|
||||
keys.add(_toGlobalKey(item.id, serverId: serverId));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate ||
|
||||
@@ -213,16 +199,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.globalKey == sourceGlobalKey) {
|
||||
loadedItems[entry.key] = updatedMetadata;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browse-specific state (not in base class)
|
||||
List<MediaFilter> _filters = [];
|
||||
List<MediaSort> _sortOptions = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../media/library_query.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../mixins/standard_paginated_view.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/error_message_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
@@ -43,6 +44,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
with
|
||||
LibraryTabFocusMixin<LibraryCollectionsTab>,
|
||||
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
|
||||
StandardPaginatedView<MediaItem, LibraryCollectionsTab>,
|
||||
SkeletonUpgradeScheduler {
|
||||
static const int _pageSize = 36;
|
||||
|
||||
@@ -78,35 +80,11 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
String? loadErrorMessage;
|
||||
await loadInitialPaginatedItems(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
|
||||
isLoading = false;
|
||||
},
|
||||
onLoaded: (_, _) {
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
|
||||
},
|
||||
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||
onLoaded: (_, _) => markItemsLoaded(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../media/media_kind.dart';
|
||||
import '../../../media/media_playlist.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../mixins/standard_paginated_view.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/error_message_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
@@ -45,6 +46,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
||||
with
|
||||
LibraryTabFocusMixin<LibraryPlaylistsTab>,
|
||||
PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab>,
|
||||
StandardPaginatedView<MediaPlaylist, LibraryPlaylistsTab>,
|
||||
SkeletonUpgradeScheduler {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@@ -84,35 +86,11 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
String? loadErrorMessage;
|
||||
await loadInitialPaginatedItems(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
|
||||
isLoading = false;
|
||||
},
|
||||
onLoaded: (_, _) {
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
|
||||
},
|
||||
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||
onLoaded: (_, _) => markItemsLoaded(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/deletion_notifier.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/hub_icons.dart';
|
||||
import '../../../utils/media_event_keys.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
import '../../../utils/watch_state_notifier.dart';
|
||||
@@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware {
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
@@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
/// Every item on screen, across all hubs.
|
||||
Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
|
||||
|
||||
// Deletion filtering needs the same id sets as watch state: each visible
|
||||
// item plus its parents, so deleting a season/show also matches the
|
||||
// episodes it contains here.
|
||||
// Deletion mirrors these via DeletionMirrorsWatchState: each visible item
|
||||
// plus its parents, so deleting a season/show also matches the episodes it
|
||||
// contains here.
|
||||
@override
|
||||
Set<String>? get deletionIds => watchedIds;
|
||||
Set<String>? get watchedIds => hierarchicalEventIds(_visibleItems);
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
|
||||
|
||||
@override
|
||||
Set<String>? get watchedIds {
|
||||
final keys = <String>{};
|
||||
for (final hub in items) {
|
||||
for (final item in hub.items) {
|
||||
keys.add(item.id);
|
||||
if (item.parentId != null) keys.add(item.parentId!);
|
||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get watchedGlobalKeys {
|
||||
final keys = <String>{};
|
||||
for (final hub in items) {
|
||||
for (final item in hub.items) {
|
||||
final serverId = item.serverId ?? widget.library.serverId;
|
||||
if (serverId == null) return null;
|
||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
Set<String>? get watchedGlobalKeys =>
|
||||
hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId);
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
@@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
||||
hub: hub,
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: _getHubIcon(hub),
|
||||
icon: hubIconFor(hub),
|
||||
isInContinueWatching: isContinueWatching,
|
||||
usesContinueWatchingAction: usesContinueWatchingAction,
|
||||
onRefresh: updateItem,
|
||||
@@ -351,7 +325,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
key: _tvBrowseRailKey,
|
||||
hubs: tvHubs,
|
||||
focusMemory: _hubFocusMemory,
|
||||
iconForHub: (hub, _) => _getHubIcon(hub),
|
||||
iconForHub: (hub, _) => hubIconFor(hub),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||
@@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
// Reload all data to refresh the continue watching section
|
||||
loadItems();
|
||||
}
|
||||
|
||||
IconData _getHubIcon(MediaHub hub) {
|
||||
final title = hub.title.toLowerCase();
|
||||
if (title.contains('continue watching') || title.contains('on deck')) {
|
||||
return Symbols.play_circle_rounded;
|
||||
} else if (title.contains('recently') || title.contains('new')) {
|
||||
return Symbols.fiber_new_rounded;
|
||||
} else if (title.contains('popular') || title.contains('trending')) {
|
||||
return Symbols.trending_up_rounded;
|
||||
} else if (title.contains('top') || title.contains('rated')) {
|
||||
return Symbols.star_rounded;
|
||||
} else if (title.contains('recommended')) {
|
||||
return Symbols.thumb_up_rounded;
|
||||
} else if (title.contains('unwatched')) {
|
||||
return Symbols.visibility_off_rounded;
|
||||
} else if (title.contains('genre')) {
|
||||
return Symbols.category_rounded;
|
||||
}
|
||||
return Symbols.movie_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,61 +166,56 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
await _recordingsTabKey.currentState?.reload();
|
||||
return;
|
||||
}
|
||||
await _serverReloadGuide();
|
||||
await _broadcastToDvrs(
|
||||
actionLabel: 'Reload guide',
|
||||
successMessage: t.liveTv.guideReloadRequested,
|
||||
action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey),
|
||||
);
|
||||
await _loadChannels();
|
||||
}
|
||||
|
||||
Future<void> _serverReloadGuide() async {
|
||||
/// Runs [action] on every DVR-capable Live TV server in parallel, then reports
|
||||
/// [successMessage]. Per-DVR failures are non-fatal — 403 (admin only) and
|
||||
/// transient errors are logged under [actionLabel] and swallowed, since
|
||||
/// callers re-fetch their own client-side state regardless. Returns `true`
|
||||
/// once at least one DVR was reached and this widget is still mounted.
|
||||
Future<bool> _broadcastToDvrs({
|
||||
required String actionLabel,
|
||||
required String successMessage,
|
||||
required Future<void> Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action,
|
||||
}) async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
Future<void> runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async {
|
||||
try {
|
||||
await action(dvr, serverInfo);
|
||||
} catch (e) {
|
||||
appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final futures = <Future<void>>[];
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null || client.liveTvDvr == null) continue;
|
||||
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey));
|
||||
final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr;
|
||||
if (dvr == null) continue;
|
||||
futures.add(runSafely(dvr, serverInfo));
|
||||
}
|
||||
if (futures.isEmpty) return;
|
||||
if (futures.isEmpty) return false;
|
||||
await Future.wait(futures);
|
||||
if (!mounted) return;
|
||||
showSnackBar(context, t.liveTv.guideReloadRequested);
|
||||
}
|
||||
|
||||
Future<void> _reloadGuideSafe(MediaServerClient client, String dvrId) async {
|
||||
try {
|
||||
final dvr = client.liveTvDvr;
|
||||
if (dvr == null) return;
|
||||
await dvr.reloadGuide(dvrId);
|
||||
} catch (e) {
|
||||
// 403 (admin only) and transient errors are non-fatal — caller still
|
||||
// re-fetches client-side channels.
|
||||
appLogger.d('Reload guide failed for DVR $dvrId: $e');
|
||||
}
|
||||
if (!mounted) return false;
|
||||
showSnackBar(context, successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _processRecordingRules() async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final futures = <Future<void>>[];
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null || client.liveTvDvr == null) continue;
|
||||
futures.add(_processRulesSafe(client));
|
||||
}
|
||||
if (futures.isEmpty) return;
|
||||
await Future.wait(futures);
|
||||
if (!mounted) return;
|
||||
showSnackBar(context, t.liveTv.rulesProcessRequested);
|
||||
final reached = await _broadcastToDvrs(
|
||||
actionLabel: 'processRecordingRules',
|
||||
successMessage: t.liveTv.rulesProcessRequested,
|
||||
action: (dvr, _) => dvr.processRecordingRules(),
|
||||
);
|
||||
if (!reached) return;
|
||||
await _recordingsTabKey.currentState?.reload();
|
||||
}
|
||||
|
||||
Future<void> _processRulesSafe(MediaServerClient client) async {
|
||||
try {
|
||||
final dvr = client.liveTvDvr;
|
||||
if (dvr == null) return;
|
||||
await dvr.processRecordingRules();
|
||||
} catch (e) {
|
||||
appLogger.d('processRecordingRules failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute visible tabs from the current MultiServerProvider state.
|
||||
/// Re-inits the tab controller when the visible set changes (matches the
|
||||
/// libraries-screen pattern at libraries_screen.dart:365).
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../media/ids.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/dpad_reorder_mixin.dart';
|
||||
import '../../focus/focus_theme.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/livetv_channel.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
@@ -34,18 +32,33 @@ class ReorderFavoritesSheet extends StatefulWidget {
|
||||
State<ReorderFavoritesSheet> createState() => _ReorderFavoritesSheetState();
|
||||
}
|
||||
|
||||
class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet>
|
||||
with DpadReorderListMixin<FavoriteChannel, ReorderFavoritesSheet> {
|
||||
late List<FavoriteChannel> _tempFavorites;
|
||||
|
||||
// Keyboard navigation state
|
||||
int _focusedIndex = 0;
|
||||
int _focusedColumn = 0; // 0 = row, 1 = remove button
|
||||
int? _movingIndex;
|
||||
int? _originalIndex;
|
||||
List<FavoriteChannel>? _originalOrder;
|
||||
final FocusNode _listFocusNode = FocusNode();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _backKeyDownSeen = false;
|
||||
|
||||
// Keyboard navigation: column 0 = row, column 1 = remove button.
|
||||
@override
|
||||
List<FavoriteChannel> get reorderItems => _tempFavorites;
|
||||
|
||||
@override
|
||||
set reorderItems(List<FavoriteChannel> value) => _tempFavorites = value;
|
||||
|
||||
@override
|
||||
int get lastReorderColumn => 1;
|
||||
|
||||
@override
|
||||
ScrollController? get reorderScrollController => _scrollController;
|
||||
|
||||
@override
|
||||
void onReorderMoveConfirmed() => widget.onReorder(_tempFavorites);
|
||||
|
||||
@override
|
||||
void onReorderColumnActivated(int column, int index) {
|
||||
if (column == 1) _removeItem(index);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,139 +73,6 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureFocusedVisible() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
|
||||
const double itemHeight = 72.0;
|
||||
const double listTopPadding = 8.0;
|
||||
final double targetTop = listTopPadding + (_focusedIndex * itemHeight);
|
||||
final double targetBottom = targetTop + itemHeight;
|
||||
|
||||
final double viewportTop = _scrollController.offset;
|
||||
final double viewportHeight = _scrollController.position.viewportDimension;
|
||||
final double viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||
|
||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
|
||||
_scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key.isBackKey) {
|
||||
if (event is KeyDownEvent) {
|
||||
_backKeyDownSeen = true;
|
||||
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event is KeyUpEvent) {
|
||||
_backKeyDownSeen = false;
|
||||
}
|
||||
}
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (_movingIndex != null) {
|
||||
setState(() {
|
||||
if (_originalOrder != null) {
|
||||
_tempFavorites = List.from(_originalOrder!);
|
||||
}
|
||||
_focusedIndex = _originalIndex ?? 0;
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
} else {
|
||||
OverlaySheetController.popAdaptive(context);
|
||||
}
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (_movingIndex != null) {
|
||||
if (key.isUpKey && _movingIndex! > 0) {
|
||||
setState(() {
|
||||
final item = _tempFavorites.removeAt(_movingIndex!);
|
||||
_tempFavorites.insert(_movingIndex! - 1, item);
|
||||
_movingIndex = _movingIndex! - 1;
|
||||
_focusedIndex = _movingIndex!;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) {
|
||||
setState(() {
|
||||
final item = _tempFavorites.removeAt(_movingIndex!);
|
||||
_tempFavorites.insert(_movingIndex! + 1, item);
|
||||
_movingIndex = _movingIndex! + 1;
|
||||
_focusedIndex = _movingIndex!;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
widget.onReorder(_tempFavorites);
|
||||
setState(() {
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
if (key.isUpKey && _focusedIndex > 0) {
|
||||
setState(() {
|
||||
_focusedIndex--;
|
||||
_focusedColumn = 0;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) {
|
||||
setState(() {
|
||||
_focusedIndex++;
|
||||
_focusedColumn = 0;
|
||||
});
|
||||
_ensureFocusedVisible();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isLeftKey && _focusedColumn > 0) {
|
||||
setState(() => _focusedColumn--);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && _focusedColumn < 1) {
|
||||
setState(() => _focusedColumn++);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isSelectKey) {
|
||||
if (_focusedColumn == 0) {
|
||||
setState(() {
|
||||
_movingIndex = _focusedIndex;
|
||||
_originalIndex = _focusedIndex;
|
||||
_originalOrder = List.from(_tempFavorites);
|
||||
});
|
||||
} else if (_focusedColumn == 1) {
|
||||
_removeItem(_focusedIndex);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.isDpadDirection) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
final item = _tempFavorites.removeAt(oldIndex);
|
||||
@@ -205,8 +85,8 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
final removed = _tempFavorites[index];
|
||||
setState(() {
|
||||
_tempFavorites.removeAt(index);
|
||||
if (_focusedIndex >= _tempFavorites.length) {
|
||||
_focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length);
|
||||
if (focusedIndex >= _tempFavorites.length) {
|
||||
focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length);
|
||||
}
|
||||
});
|
||||
widget.onRemove(removed);
|
||||
@@ -229,7 +109,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
focusNode: _listFocusNode,
|
||||
descendantsAreFocusable: false,
|
||||
autofocus: isKeyboardMode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
onKeyEvent: handleReorderKeyEvent,
|
||||
child: ReorderableListView.builder(
|
||||
scrollController: _scrollController,
|
||||
onReorderItem: _onReorder,
|
||||
@@ -239,8 +119,8 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
itemBuilder: (context, index) {
|
||||
final fav = _tempFavorites[index];
|
||||
final channel = widget.channelMap[fav.stableKey];
|
||||
final isFocused = isKeyboardMode && index == _focusedIndex;
|
||||
final isMoving = index == _movingIndex;
|
||||
final isFocused = isKeyboardMode && index == focusedIndex;
|
||||
final isMoving = index == movingIndex;
|
||||
|
||||
return _buildFavoriteTile(
|
||||
key: ValueKey(fav.stableKey),
|
||||
@@ -249,7 +129,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
||||
index: index,
|
||||
isFocused: isFocused,
|
||||
isMoving: isMoving,
|
||||
focusedColumn: isFocused ? _focusedColumn : null,
|
||||
focusedColumn: isFocused ? focusedColumn : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+87
-142
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../navigation/main_screen_scope.dart';
|
||||
import 'dart:io' show Platform, exit;
|
||||
|
||||
@@ -34,6 +35,7 @@ import '../profiles/active_profile_binder.dart';
|
||||
import '../connection/connection_registry.dart';
|
||||
import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile_selection_policy.dart';
|
||||
import '../providers/catalog_sources_provider.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
@@ -233,13 +235,12 @@ class _MainScreenState extends State<MainScreen>
|
||||
bool _isShowingProfileSelection = false;
|
||||
|
||||
late List<Widget> _screens;
|
||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||
final GlobalKey<State<ExploreScreen>> _exploreKey = GlobalKey();
|
||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
||||
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
||||
final GlobalKey<State<DownloadsScreen>> _downloadsKey = GlobalKey();
|
||||
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
|
||||
|
||||
/// One [GlobalKey] per tab, so a tab's live [State] can be reached from
|
||||
/// anywhere in this class via [_onScreen]. Deliberately untyped: every
|
||||
/// consumer discards the concrete `State<X>` type and pattern-matches on a
|
||||
/// capability mixin (Refreshable, FocusableTab, …) instead.
|
||||
final Map<NavigationTabId, GlobalKey> _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()};
|
||||
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
|
||||
|
||||
/// Measures the mobile bottom navigation area for the music mini-player.
|
||||
@@ -441,23 +442,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
}
|
||||
|
||||
void tryDownloadResume() {
|
||||
if (_downloadResumeFired || !mounted) return;
|
||||
// Wait for any online client before firing the resume — the download
|
||||
// pipeline is backend-neutral (resumeQueuedDownloads accepts a
|
||||
// MediaServerClient and per-item resolution picks up the right
|
||||
// backend), so a Jellyfin-only setup can resume too.
|
||||
final onlineClient = manager.onlineClients.values.firstOrNull;
|
||||
if (onlineClient == null) return;
|
||||
_downloadResumeFired = true;
|
||||
_serverStatusSub?.cancel();
|
||||
_serverStatusSub = null;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
unawaited(
|
||||
downloadProvider.ensureInitialized().then((_) {
|
||||
if (!mounted) return;
|
||||
downloadProvider.resumeQueuedDownloads(onlineClient);
|
||||
}),
|
||||
);
|
||||
_resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull);
|
||||
}
|
||||
|
||||
// Listen for binding-settle so the once-only priming runs after both
|
||||
@@ -495,36 +484,35 @@ class _MainScreenState extends State<MainScreen>
|
||||
if (!mounted) return;
|
||||
context.read<OfflineWatchSyncService>().onServersConnected();
|
||||
unawaited(context.read<DownloadProvider>().refreshMetadataFromCache());
|
||||
_resumeQueuedDownloadsIfPossible(mp);
|
||||
_resumeQueuedDownloadsOnce(
|
||||
mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (_discoverKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
if (_librariesKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
if (_searchKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
_fullRefreshContentTabs();
|
||||
}
|
||||
|
||||
void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) {
|
||||
/// Single-shot "resume queued downloads once any client is online" rule,
|
||||
/// shared by the startup status-stream path and [_primeOnlineServices] —
|
||||
/// each caller resolves its own candidate client (unfiltered manager view
|
||||
/// vs the visibility-filtered provider) and hands it here. No-op once the
|
||||
/// resume has fired, or while no client is online yet.
|
||||
void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) {
|
||||
if (_downloadResumeFired || !mounted) return;
|
||||
for (final serverId in mp.onlineServerIds) {
|
||||
final onlineClient = mp.getClientForServer(ServerId(serverId));
|
||||
if (onlineClient == null) continue;
|
||||
_downloadResumeFired = true;
|
||||
unawaited(
|
||||
context.read<DownloadProvider>().ensureInitialized().then((_) {
|
||||
if (!mounted) return;
|
||||
context.read<DownloadProvider>().resumeQueuedDownloads(onlineClient);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (onlineClient == null) return;
|
||||
_downloadResumeFired = true;
|
||||
// The status subscription exists only to drive this one-shot.
|
||||
_serverStatusSub?.cancel();
|
||||
_serverStatusSub = null;
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
unawaited(
|
||||
downloadProvider.ensureInitialized().then((_) {
|
||||
if (!mounted) return;
|
||||
downloadProvider.resumeQueuedDownloads(onlineClient);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _onActiveProfileChanged() {
|
||||
@@ -594,11 +582,15 @@ class _MainScreenState extends State<MainScreen>
|
||||
// has no profile to bind, and the user lands on an empty screen with
|
||||
// no way back to the picker.
|
||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||
final requireOnOpen =
|
||||
settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
||||
|
||||
if (!hasNoActive && !requireOnOpen) return;
|
||||
if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return;
|
||||
|
||||
await _pushProfileSelection();
|
||||
}
|
||||
|
||||
/// Push the picker in "must choose" mode, suppressing the tvOS menu-button
|
||||
/// passthrough for as long as it is up.
|
||||
Future<void> _pushProfileSelection() async {
|
||||
_isShowingProfileSelection = true;
|
||||
_setTvosMenuPassthrough(false);
|
||||
await Navigator.of(
|
||||
@@ -838,9 +830,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
_selectTab(NavigationTabId.search, focusSearchInput: !hasQuery);
|
||||
if (hasQuery) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.submitSearchQuery(trimmed);
|
||||
}
|
||||
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -925,21 +915,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
Future<void> _showProfileSelectionOnResume() async {
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
if (!activeProfile.hasMultipleProfiles) return;
|
||||
if (!context.read<ActiveProfileProvider>().requiresSelectionOnOpen(settingsService)) return;
|
||||
|
||||
_isShowingProfileSelection = true;
|
||||
_setTvosMenuPassthrough(false);
|
||||
await Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true)));
|
||||
if (!mounted) return;
|
||||
_isShowingProfileSelection = false;
|
||||
_updateTvosMenuPassthrough();
|
||||
await _pushProfileSelection();
|
||||
}
|
||||
|
||||
/// IndexedStack that disables tickers for offscreen children to prevent
|
||||
@@ -965,17 +945,17 @@ class _MainScreenState extends State<MainScreen>
|
||||
return [
|
||||
for (final tab in _getVisibleTabs(offline))
|
||||
switch (tab.id) {
|
||||
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
||||
NavigationTabId.explore => ExploreScreen(key: _exploreKey),
|
||||
NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]),
|
||||
NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]),
|
||||
NavigationTabId.libraries => LibrariesScreen(
|
||||
key: _librariesKey,
|
||||
key: _screenKeys[tab.id],
|
||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||
onLibrarySelected: _handleLibrariesScreenSelected,
|
||||
),
|
||||
NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey),
|
||||
NavigationTabId.search => SearchScreen(key: _searchKey),
|
||||
NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey),
|
||||
NavigationTabId.settings => SettingsScreen(key: _settingsKey),
|
||||
NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]),
|
||||
NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]),
|
||||
NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]),
|
||||
NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1030,16 +1010,22 @@ class _MainScreenState extends State<MainScreen>
|
||||
}());
|
||||
}
|
||||
|
||||
void _handleLiveTvChanged() {
|
||||
final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false;
|
||||
if (hasLiveTv == _lastHasLiveTv) return;
|
||||
_lastHasLiveTv = hasLiveTv;
|
||||
|
||||
/// Rebuilds navigation after a tab's availability flipped: _currentTab may
|
||||
/// need normalizing, and passthrough depends on it being the first tab.
|
||||
void _handleTabAvailabilityChanged() {
|
||||
setState(() {
|
||||
_screens = _buildScreens(_isOffline);
|
||||
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
||||
});
|
||||
_updateTvosMenuPassthrough();
|
||||
}
|
||||
|
||||
void _handleLiveTvChanged() {
|
||||
final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false;
|
||||
if (hasLiveTv == _lastHasLiveTv) return;
|
||||
_lastHasLiveTv = hasLiveTv;
|
||||
|
||||
_handleTabAvailabilityChanged();
|
||||
|
||||
// A preferred startup section (only Live TV can be deferred) just became
|
||||
// available — switch to it via _selectTab so it gets the usual visibility
|
||||
@@ -1055,13 +1041,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
if (hasExplore == _lastHasExplore) return;
|
||||
_lastHasExplore = hasExplore;
|
||||
|
||||
setState(() {
|
||||
_screens = _buildScreens(_isOffline);
|
||||
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
||||
});
|
||||
// Same as the live-TV handler: the passthrough flag depends on whether
|
||||
// _currentTab is the first tab, which the normalize above can change.
|
||||
_updateTvosMenuPassthrough();
|
||||
_handleTabAvailabilityChanged();
|
||||
}
|
||||
|
||||
void _handleOfflineStatusChanged() {
|
||||
@@ -1154,17 +1134,8 @@ class _MainScreenState extends State<MainScreen>
|
||||
// This preserves the user's focus position when returning from sidebar.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (restorePreviousFocus) {
|
||||
if (_contentFocusScope.focusedChild == null) {
|
||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
}
|
||||
if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return;
|
||||
_onScreen<FocusableTab>(_currentTab, (screen) => screen.focusActiveTabIfReady());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1363,9 +1334,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
if (_isSidebarFocused) _focusContent();
|
||||
// Schedule focus after the frame so the search screen is visible in the IndexedStack
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.focusSearchInput();
|
||||
}
|
||||
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.focusSearchInput());
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -1386,9 +1355,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
_miniPlayerInsets?.setNavBarSuspended(true);
|
||||
// Called when a child route is pushed on top (e.g., video player)
|
||||
if (_currentTab == NavigationTabId.discover) {
|
||||
if (_discoverKey.currentState case final TabVisibilityAware aware) {
|
||||
aware.onTabHidden();
|
||||
}
|
||||
_onScreen<TabVisibilityAware>(NavigationTabId.discover, (screen) => screen.onTabHidden());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1407,9 +1374,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
_updateTvosMenuPassthrough();
|
||||
_miniPlayerInsets?.setNavBarSuspended(false);
|
||||
if (_currentTab == NavigationTabId.discover) {
|
||||
if (_discoverKey.currentState case final TabVisibilityAware aware) {
|
||||
aware.onTabShown();
|
||||
}
|
||||
_onScreen<TabVisibilityAware>(NavigationTabId.discover, (screen) => screen.onTabShown());
|
||||
_onDiscoverBecameVisible();
|
||||
}
|
||||
}
|
||||
@@ -1417,9 +1382,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
void _onDiscoverBecameVisible() {
|
||||
appLogger.d('Navigated to home');
|
||||
// Refresh content when returning to discover page
|
||||
if (_discoverKey.currentState case final Refreshable refreshable) {
|
||||
refreshable.refresh();
|
||||
}
|
||||
_onScreen<Refreshable>(NavigationTabId.discover, (screen) => screen.refresh());
|
||||
}
|
||||
|
||||
void _onLibraryOrderChanged() {
|
||||
@@ -1464,15 +1427,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
playbackStateProvider.clearShuffle();
|
||||
|
||||
if (_discoverKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
if (_librariesKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
if (_searchKey.currentState case final FullRefreshable refreshable) {
|
||||
refreshable.fullRefresh();
|
||||
}
|
||||
_fullRefreshContentTabs();
|
||||
|
||||
// Refresh user-level settings (audio/sub defaults) for the new identity.
|
||||
if (mounted) {
|
||||
@@ -1500,14 +1455,9 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
if (previousTab != tab) {
|
||||
// Notify previous screen it's being hidden
|
||||
if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) {
|
||||
aware.onTabHidden();
|
||||
}
|
||||
_onScreen<TabVisibilityAware>(previousTab, (screen) => screen.onTabHidden());
|
||||
// Notify and focus new screen
|
||||
final newState = _screenKeyFor(tab)?.currentState;
|
||||
if (newState case final TabVisibilityAware aware) {
|
||||
aware.onTabShown();
|
||||
}
|
||||
_onScreen<TabVisibilityAware>(tab, (screen) => screen.onTabShown());
|
||||
// Back-to-home keeps the sidebar focused (chain: content → sidebar →
|
||||
// home → exit); stealing focus here left _isSidebarFocused stuck true
|
||||
// while real focus sat on a content card (#1411).
|
||||
@@ -1515,9 +1465,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
// search input, since focusing it auto-opens the on-screen keyboard; the
|
||||
// query submit focuses results instead.
|
||||
if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) {
|
||||
if (newState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
_onScreen<FocusableTab>(tab, (screen) => screen.focusActiveTabIfReady());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1531,9 +1479,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
// submit runs the search and focuses results without opening the keyboard.
|
||||
if (tab == NavigationTabId.search && focusSearchInput) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.focusSearchInput();
|
||||
}
|
||||
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.focusSearchInput());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1543,12 +1489,8 @@ class _MainScreenState extends State<MainScreen>
|
||||
_selectedLibraryGlobalKey = libraryGlobalKey;
|
||||
_selectTab(NavigationTabId.libraries);
|
||||
// Tell LibrariesScreen to load this library after tab switch
|
||||
if (_librariesKey.currentState case final LibraryLoadable loadable) {
|
||||
loadable.loadLibraryByKey(libraryGlobalKey);
|
||||
}
|
||||
if (_librariesKey.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
_onScreen<LibraryLoadable>(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey));
|
||||
_onScreen<FocusableTab>(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady());
|
||||
}
|
||||
|
||||
void _openSettings() {
|
||||
@@ -1637,17 +1579,20 @@ class _MainScreenState extends State<MainScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the GlobalKey for a given tab.
|
||||
GlobalKey? _screenKeyFor(NavigationTabId tab) {
|
||||
return switch (tab) {
|
||||
NavigationTabId.discover => _discoverKey,
|
||||
NavigationTabId.explore => _exploreKey,
|
||||
NavigationTabId.libraries => _librariesKey,
|
||||
NavigationTabId.liveTv => _liveTvKey,
|
||||
NavigationTabId.search => _searchKey,
|
||||
NavigationTabId.downloads => _downloadsKey,
|
||||
NavigationTabId.settings => _settingsKey,
|
||||
};
|
||||
/// Invoke [fn] on the tab's current [State] when it exists and implements
|
||||
/// the capability [T]. Screens are only built for visible tabs and mount a
|
||||
/// frame later, so a missing key or a non-matching state is a no-op.
|
||||
void _onScreen<T>(NavigationTabId tab, void Function(T state) fn) {
|
||||
if (_screenKeys[tab]?.currentState case final T state) fn(state);
|
||||
}
|
||||
|
||||
/// Full-refresh the primary content tabs. Shared by the online-entry hook
|
||||
/// ([_primeOnlineServices]) and the profile-switch invalidation
|
||||
/// ([_invalidateAllScreens]), which refresh the same set.
|
||||
void _fullRefreshContentTabs() {
|
||||
for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) {
|
||||
_onScreen<FullRefreshable>(tab, (screen) => screen.fullRefresh());
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
|
||||
|
||||
@@ -267,7 +267,13 @@ PageRoute<bool> mediaDetailRoute({
|
||||
}
|
||||
|
||||
class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware {
|
||||
with
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
DeletionMirrorsWatchState,
|
||||
MountedSetStateMixin,
|
||||
ServerBoundMediaMixin,
|
||||
RouteAware {
|
||||
/// Public input alias — used as the live source of truth until the detail
|
||||
/// fetch returns. Holds backend-neutral [MediaItem] data.
|
||||
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
|
||||
@@ -393,7 +399,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
@override
|
||||
bool get isServerBoundOffline => widget.isOffline;
|
||||
|
||||
// WatchStateAware: watch the show/movie and all season/episode ratingKeys
|
||||
// WatchStateAware: watch the show/movie and all season/episode ratingKeys.
|
||||
// DeletionMirrorsWatchState reuses these three getters for deletion events —
|
||||
// the same items are on screen either way.
|
||||
@override
|
||||
Set<String>? get watchedIds {
|
||||
final keys = <String>{_metadata.id};
|
||||
@@ -533,36 +541,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Set<String>? get deletionIds {
|
||||
final keys = <String>{_metadata.id};
|
||||
for (final season in _seasons) {
|
||||
keys.add(season.id);
|
||||
}
|
||||
for (final ep in _episodes) {
|
||||
keys.add(ep.id);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get deletionServerId => serverBoundServerId;
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys {
|
||||
final serverId = serverBoundServerId;
|
||||
if (serverId == null) return null;
|
||||
|
||||
final keys = <String>{toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))};
|
||||
for (final season in _seasons) {
|
||||
keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId)));
|
||||
}
|
||||
for (final ep in _episodes) {
|
||||
keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId)));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@override
|
||||
void onDeletionEvent(DeletionEvent event) {
|
||||
// Download-only deletions should only remove items when viewing offline content
|
||||
|
||||
@@ -122,23 +122,14 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
|
||||
final draft = _draft;
|
||||
if (draft == null || _isCommitting) return;
|
||||
final currentValue = draft.value<String>(field.id) ?? '';
|
||||
final result = multiline
|
||||
? await showTextInputDialog(
|
||||
context,
|
||||
title: field.label,
|
||||
labelText: field.label,
|
||||
initialValue: currentValue,
|
||||
allowEmpty: true,
|
||||
multiline: true,
|
||||
)
|
||||
: await showTextInputDialog(
|
||||
context,
|
||||
title: field.label,
|
||||
labelText: field.label,
|
||||
hintText: '',
|
||||
initialValue: currentValue,
|
||||
allowEmpty: true,
|
||||
);
|
||||
final result = await showTextInputDialog(
|
||||
context,
|
||||
title: field.label,
|
||||
labelText: field.label,
|
||||
initialValue: currentValue,
|
||||
allowEmpty: true,
|
||||
multiline: multiline,
|
||||
);
|
||||
|
||||
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
||||
setState(() => draft.setValue(field.id, result));
|
||||
|
||||
@@ -27,14 +27,12 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/download_status_icon.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../../widgets/media_context_menu.dart';
|
||||
import '../../widgets/music/mini_player.dart';
|
||||
import '../../widgets/music/music_detail_header.dart';
|
||||
import '../../widgets/music/music_actions.dart';
|
||||
import '../../widgets/music/track_row.dart';
|
||||
import '../../widgets/optimized_media_image.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
import '../focusable_detail_screen_mixin.dart';
|
||||
|
||||
@@ -388,35 +386,15 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PrimaryScrollController(
|
||||
controller: scrollController,
|
||||
child: IosStatusBarTapScrollToTop(
|
||||
controller: scrollController,
|
||||
child: OverlaySheetHost(
|
||||
// Host owns sheet + system back: a back with a sheet open closes it;
|
||||
// otherwise focus the action row first, then pop.
|
||||
canPop: PlatformDetector.isHandheldIOS(context),
|
||||
onSystemBack: () {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (handleBackNavigation() && mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.album.displayTitle)),
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
...buildStateSlivers(),
|
||||
if (hasItems) _buildTrackList(),
|
||||
// Keep the last rows reachable above the floating mini-player.
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return buildDetailScaffold(
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.album.displayTitle)),
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
...buildStateSlivers(),
|
||||
if (hasItems) _buildTrackList(),
|
||||
// Keep the last rows reachable above the floating mini-player.
|
||||
SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../media/ids.dart';
|
||||
import '../../media/media_item.dart';
|
||||
@@ -16,17 +15,14 @@ import '../../utils/formatters.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/media_image_helper.dart';
|
||||
import '../../utils/music_navigation.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/collapsible_text.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../../widgets/music/mini_player.dart';
|
||||
import '../../widgets/music/music_detail_header.dart';
|
||||
import '../../widgets/music/music_actions.dart';
|
||||
import '../../widgets/optimized_media_image.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
import '../focusable_detail_screen_mixin.dart';
|
||||
|
||||
@@ -81,31 +77,17 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
/// listing this screen loads, so this costs one extra server round-trip —
|
||||
/// gated on playback availability first so the stub never fetches.
|
||||
Future<void> _playAll({bool shuffle = false}) async {
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
List<MediaItem> tracks;
|
||||
try {
|
||||
tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle);
|
||||
showErrorSnackBar(context, message);
|
||||
return;
|
||||
}
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
if (tracks.isEmpty) {
|
||||
showAppSnackBar(context, emptyMessage);
|
||||
return;
|
||||
}
|
||||
await playTracks(
|
||||
await playFetchedTracks(
|
||||
context,
|
||||
tracks: tracks,
|
||||
fetch: () => mediaClient.fetchPlayableDescendants(widget.artist.id),
|
||||
playContext: MusicPlayContext(
|
||||
id: widget.artist.id,
|
||||
title: widget.artist.displayTitle,
|
||||
kind: MusicPlayContextKind.artist,
|
||||
),
|
||||
onError: (e, stackTrace) =>
|
||||
showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle)),
|
||||
onEmpty: () => showAppSnackBar(context, emptyMessage),
|
||||
shuffle: shuffle,
|
||||
);
|
||||
}
|
||||
@@ -193,36 +175,16 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PrimaryScrollController(
|
||||
controller: scrollController,
|
||||
child: IosStatusBarTapScrollToTop(
|
||||
controller: scrollController,
|
||||
child: OverlaySheetHost(
|
||||
// Host owns sheet + system back: a back with a sheet open closes it;
|
||||
// otherwise focus the action row first, then pop.
|
||||
canPop: PlatformDetector.isHandheldIOS(context),
|
||||
onSystemBack: () {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (handleBackNavigation() && mounted) Navigator.pop(context);
|
||||
},
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
primary: true,
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.artist.displayTitle)),
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
...buildStateSlivers(),
|
||||
// Albums arrive newest-first from both backends — no client-side sort.
|
||||
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
|
||||
// Keep the last rows reachable above the floating mini-player.
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
return buildDetailScaffold(
|
||||
slivers: [
|
||||
CustomAppBar(title: Text(widget.artist.displayTitle)),
|
||||
SliverToBoxAdapter(child: _buildHeader()),
|
||||
...buildStateSlivers(),
|
||||
// Albums arrive newest-first from both backends — no client-side sort.
|
||||
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
|
||||
// Keep the last rows reachable above the floating mini-player.
|
||||
SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:async';
|
||||
import '../../media/ids.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_action_bar.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../media/library_query.dart';
|
||||
import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
@@ -34,6 +32,7 @@ import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
import '../../widgets/listenable_selector.dart';
|
||||
import '../base_media_list_detail_screen.dart';
|
||||
import '../focusable_detail_screen_mixin.dart';
|
||||
import '../libraries/content_state_builder.dart';
|
||||
import '../../mixins/grid_focus_node_mixin.dart';
|
||||
import '../../widgets/overlay_sheet.dart';
|
||||
|
||||
@@ -92,24 +91,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
showAppSnackBar(context, emptyMessage);
|
||||
return;
|
||||
}
|
||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
||||
final service = context.read<MusicPlaybackService>();
|
||||
final intent = service.beginPlayIntent();
|
||||
List<MediaItem> tracks;
|
||||
if (_isPlaylistFullyLoaded) {
|
||||
tracks = items;
|
||||
} else {
|
||||
try {
|
||||
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
|
||||
showErrorSnackBar(context, message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
||||
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
|
||||
await playFetchedTracks(
|
||||
context,
|
||||
fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id),
|
||||
playContext: _musicPlayContext,
|
||||
onError: (e, stackTrace) =>
|
||||
showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)),
|
||||
startTrack: startTrack,
|
||||
shuffle: shuffle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -117,7 +107,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
// Video AND audio playlists download (tracks queue through the same list
|
||||
// pipeline); photo/mixed playlists keep the affordance hidden.
|
||||
final isDownloadablePlaylist = widget.playlist.playlistType == 'video' || _isAudioPlaylist;
|
||||
final ruleKey = _playlistSyncRuleKey();
|
||||
final ruleKey = syncRuleKey;
|
||||
// Select the specific bool we care about so unrelated DownloadProvider
|
||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||
final hasRule = isDownloadablePlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
||||
@@ -127,19 +117,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
||||
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||
],
|
||||
if (!PlatformDetector.isAppleTV() && isDownloadablePlaylist && (items.isNotEmpty || hasRule))
|
||||
FocusableAction(
|
||||
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
||||
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
||||
onPressed: hasRule ? _managePlaylistSyncRule : _downloadPlaylist,
|
||||
iconColor: hasRule ? Colors.teal : null,
|
||||
),
|
||||
if (!PlatformDetector.isAppleTV() && hasRule)
|
||||
FocusableAction(
|
||||
icon: Symbols.sync_disabled_rounded,
|
||||
tooltip: t.downloads.removeSyncRule,
|
||||
onPressed: _removePlaylistSyncRule,
|
||||
),
|
||||
...buildSyncRuleActions(
|
||||
context,
|
||||
ruleKey: ruleKey,
|
||||
displayTitle: widget.playlist.title,
|
||||
hasRule: hasRule,
|
||||
showDownload: isDownloadablePlaylist && (items.isNotEmpty || hasRule),
|
||||
onDownload: _downloadPlaylist,
|
||||
),
|
||||
// Delete works on both backends now (Jellyfin uses /Items/{id} DELETE,
|
||||
// wrapped in the neutral [MediaServerClient.deletePlaylist]). Smart
|
||||
// playlists are still skipped — they're a Plex concept and are
|
||||
@@ -166,25 +151,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
serverName: widget.playlist.serverName,
|
||||
);
|
||||
|
||||
String _playlistSyncRuleKey() {
|
||||
final serverId = widget.playlist.serverId ?? mediaClient.serverId;
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
mediaClient,
|
||||
widget.playlist.id,
|
||||
serverId: ServerId(serverId),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _managePlaylistSyncRule() =>
|
||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistSyncRuleKey());
|
||||
|
||||
Future<void> _removePlaylistSyncRule() => removeSyncRuleAndSnack(
|
||||
context,
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
globalKey: _playlistSyncRuleKey(),
|
||||
displayTitle: widget.playlist.title,
|
||||
);
|
||||
|
||||
// Focus management for regular (non-smart) reorderable lists
|
||||
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
||||
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
|
||||
@@ -780,7 +746,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
else
|
||||
// Plex regular playlists: sliver reorderable list
|
||||
_buildReorderableList(isKeyboardMode),
|
||||
if (_continuation.isLoading || _continuation.error != null) _buildPlaylistContinuationStatusSliver(),
|
||||
if (_continuation.isLoading || _continuation.error != null)
|
||||
ContinuationStatusSliver(
|
||||
error: _continuation.error,
|
||||
onRetry: _retryPlaylistContinuation,
|
||||
retryFocusNode: _continuationRetryFocusNode,
|
||||
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
|
||||
onBack: handleBackFromContent,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -860,32 +833,4 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaylistContinuationStatusSliver() {
|
||||
final exception = _continuation.error;
|
||||
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: _continuationRetryFocusNode,
|
||||
onPressed: _retryPlaylistContinuation,
|
||||
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
|
||||
onBack: handleBackFromContent,
|
||||
child: TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +201,8 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
candidateSliver = SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final cand = candidates[index];
|
||||
// M3E connected-group geometry: large outer corners, small
|
||||
// inner corners, hairline gaps between tiles.
|
||||
final tokensRef = tokens(context);
|
||||
final tileRadii = BorderRadius.vertical(
|
||||
top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
);
|
||||
final tileRadii = groupItemRadii(context, index, candidates.length);
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0),
|
||||
child: FocusableWrapper(
|
||||
@@ -299,6 +294,8 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
final parentId = cand.source.parentConnectionId;
|
||||
final homeUuid = cand.source.plexHomeUserUuid;
|
||||
if (parentId == null || homeUuid == null) return false;
|
||||
// Built before the await: capturing the prompt needs a live element.
|
||||
final promptForPin = dialogPinPrompt(context, cand.source.displayName);
|
||||
final parent = await context.read<ConnectionRegistry>().getPlexAccount(parentId);
|
||||
if (parent == null) {
|
||||
if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount);
|
||||
@@ -308,10 +305,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
account: parent,
|
||||
homeUserUuid: homeUuid,
|
||||
requiresPin: true,
|
||||
promptForPin: ({String? errorMessage}) async {
|
||||
if (!mounted) return null;
|
||||
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
|
||||
},
|
||||
promptForPin: promptForPin,
|
||||
logLabel: cand.source.displayName,
|
||||
);
|
||||
if (!result.succeeded) {
|
||||
@@ -330,10 +324,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
account: account,
|
||||
homeUserUuid: cand.pc.userIdentifier,
|
||||
requiresPin: cand.source.plexProtected,
|
||||
promptForPin: ({String? errorMessage}) async {
|
||||
if (!mounted) return null;
|
||||
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
|
||||
},
|
||||
promptForPin: dialogPinPrompt(context, cand.source.displayName),
|
||||
persistTo: pcRegistry,
|
||||
persistProfileId: widget.targetProfile.id,
|
||||
logLabel: cand.source.displayName,
|
||||
@@ -344,14 +335,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
||||
if (widget.popOnSuccess) {
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
||||
}
|
||||
_finishBorrow();
|
||||
}
|
||||
|
||||
Future<void> _borrowJellyfin(_BorrowCandidate cand) async {
|
||||
@@ -366,14 +350,19 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
||||
tokenAcquiredAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
||||
if (widget.popOnSuccess) {
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
||||
_finishBorrow();
|
||||
}
|
||||
|
||||
/// Shared tail of every successful borrow: rebind the target profile when
|
||||
/// it is the active one, then pop with the result or confirm in place.
|
||||
void _finishBorrow() {
|
||||
if (!mounted) return;
|
||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
||||
if (widget.popOnSuccess) {
|
||||
Navigator.of(context).pop(true);
|
||||
return;
|
||||
}
|
||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../focus/key_event_utils.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/plex_home_switch.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/clickable_cursor.dart';
|
||||
@@ -698,6 +699,14 @@ Future<String?> showPinEntryDialog(BuildContext context, String userName, {Strin
|
||||
);
|
||||
}
|
||||
|
||||
/// The [PlexHomeSwitchPinPrompt] every UI-side `mintPlexHomeUserToken` caller
|
||||
/// needs: show [showPinEntryDialog] for [displayName], or cancel the switch
|
||||
/// once [context] is gone. Only the *use* is guarded — build it before the
|
||||
/// caller's first await, while [context] is still live.
|
||||
PlexHomeSwitchPinPrompt dialogPinPrompt(BuildContext context, String displayName) =>
|
||||
({String? errorMessage}) async =>
|
||||
context.mounted ? showPinEntryDialog(context, displayName, errorMessage: errorMessage) : null;
|
||||
|
||||
/// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null
|
||||
/// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch]
|
||||
/// (or no-op if not provided) and returns null — the helper keeps the UX
|
||||
|
||||
@@ -10,21 +10,13 @@ import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../models/plex/plex_home_user.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
import '../../profiles/plex_home_service.dart';
|
||||
import '../../profiles/profile.dart';
|
||||
import '../../profiles/profile_avatar.dart';
|
||||
import '../../profiles/profile_connection_cleanup.dart';
|
||||
import '../../profiles/profile_connection.dart';
|
||||
import '../../profiles/profile_connection_registry.dart';
|
||||
import '../../profiles/profile_registry.dart';
|
||||
import '../../profiles/profiles_view.dart';
|
||||
import '../../providers/download_provider.dart';
|
||||
import '../../providers/discover_provider.dart';
|
||||
import '../../providers/hidden_libraries_provider.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../services/system_shelf_service.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
@@ -167,20 +159,11 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
isDestructive: true,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
final downloads = context.read<DownloadProvider>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
final connRegistry = context.read<ConnectionRegistry>();
|
||||
final storage = context.read<StorageService>();
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final hiddenLibraries = context.read<HiddenLibrariesProvider?>();
|
||||
final discover = context.read<DiscoverProvider?>();
|
||||
final binder = context.read<ActiveProfileBinder>();
|
||||
final active = context.read<ActiveProfileProvider>();
|
||||
final shelf = SystemShelfService();
|
||||
final endedOwner = active.activeId == _profile.id ? _profile.id : null;
|
||||
final scope = SessionTeardownScope.of(context);
|
||||
final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null;
|
||||
|
||||
if (endedOwner != null) {
|
||||
await shelf.endProfileSession(endedOwner);
|
||||
await scope.shelf.endProfileSession(endedOwner);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -189,38 +172,26 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
||||
// Plex account sharing the server, another Jellyfin user).
|
||||
final retainedServerIds = await _retainedServerIds(
|
||||
excludingConnectionId: conn.id,
|
||||
profileConnections: pcRegistry,
|
||||
connections: connRegistry,
|
||||
profileConnections: scope.profileConnections,
|
||||
connections: scope.connections,
|
||||
);
|
||||
await downloads.releaseDownloadsForProfileServers(
|
||||
await scope.downloads.releaseDownloadsForProfileServers(
|
||||
_profile.id,
|
||||
_serverIdsForConnection(conn).difference(retainedServerIds),
|
||||
);
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: _profile.id,
|
||||
connection: conn,
|
||||
profileConnections: pcRegistry,
|
||||
connections: connRegistry,
|
||||
storage: storage,
|
||||
serverManager: multiServer.serverManager,
|
||||
);
|
||||
await hiddenLibraries?.refresh();
|
||||
await binder.rebindIfActive(_profile.id);
|
||||
if (endedOwner != null && active.activeId == endedOwner) {
|
||||
shelf.beginProfileSession(endedOwner);
|
||||
if (multiServer.hasConnectedServers) await discover?.load();
|
||||
await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn);
|
||||
await scope.hiddenLibraries?.refresh();
|
||||
// Deliberately not `resumeFreshSystemShelf`: a rebind failure on the
|
||||
// success path must reach the catch below so the recovery attempt —
|
||||
// and the rethrow — still run.
|
||||
await scope.binder.rebindIfActive(_profile.id);
|
||||
if (endedOwner != null && scope.active.activeId == endedOwner) {
|
||||
scope.shelf.beginProfileSession(endedOwner);
|
||||
if (scope.multiServer.hasConnectedServers) await scope.discover?.load();
|
||||
}
|
||||
} catch (_) {
|
||||
if (endedOwner != null && active.activeId == endedOwner) {
|
||||
try {
|
||||
await binder.rebindIfActive(endedOwner);
|
||||
if (active.activeId == endedOwner) {
|
||||
shelf.beginProfileSession(endedOwner);
|
||||
if (multiServer.hasConnectedServers) await discover?.load();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the shelf empty when the surviving profile cannot be rebound.
|
||||
}
|
||||
if (endedOwner != null) {
|
||||
await resumeFreshSystemShelf(scope, endedOwner);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
||||
@@ -224,13 +224,8 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final profile = profiles[index];
|
||||
final isActive = profile.id == activeId;
|
||||
// M3E connected-group geometry: large outer corners, small inner
|
||||
// corners, hairline gaps between tiles.
|
||||
final tokensRef = tokens(context);
|
||||
final tileRadii = BorderRadius.vertical(
|
||||
top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
);
|
||||
final tileRadii = groupItemRadii(context, index, profiles.length);
|
||||
final isFirstSelectable = autofocusFirst && index == 0;
|
||||
final profileFocusNode = _profileFocusNode(profile);
|
||||
final menuFocusNode = _profileMenuFocusNode(profile);
|
||||
|
||||
@@ -49,6 +49,13 @@ class SessionTeardownScope {
|
||||
|
||||
MultiServerManager get serverManager => multiServer.serverManager;
|
||||
|
||||
ProfileConnectionCleanup get cleanup => ProfileConnectionCleanup(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
|
||||
SessionTeardownScope.of(BuildContext context)
|
||||
: active = context.read<ActiveProfileProvider>(),
|
||||
binder = context.read<ActiveProfileBinder>(),
|
||||
@@ -80,13 +87,9 @@ Future<bool> settleSessionAfterRemoval(
|
||||
bool rebindIfActiveKept = false,
|
||||
String? endedShelfOwner,
|
||||
}) async {
|
||||
final result = await resolvePostRemovalState(
|
||||
final result = await scope.cleanup.resolvePostRemovalState(
|
||||
profileRegistry: scope.profileRegistry,
|
||||
profileConnections: scope.profileConnections,
|
||||
connections: scope.connections,
|
||||
plexHomeUsers: scope.plexHome.current,
|
||||
storage: scope.storage,
|
||||
serverManager: scope.serverManager,
|
||||
);
|
||||
|
||||
if (result.route == PostRemovalRoute.signedOut) {
|
||||
@@ -199,13 +202,7 @@ Future<void> deleteProfile(BuildContext context, Profile profile) async {
|
||||
await scope.downloads.deleteDownloadsForProfile(profile.id);
|
||||
await scope.database.deleteSyncRulesForProfile(profile.id);
|
||||
await scope.database.deleteWatchActionsForProfile(profile.id);
|
||||
await removeAllProfileConnectionsAndCleanup(
|
||||
profileId: profile.id,
|
||||
profileConnections: scope.profileConnections,
|
||||
connections: scope.connections,
|
||||
storage: scope.storage,
|
||||
serverManager: scope.serverManager,
|
||||
);
|
||||
await scope.cleanup.removeAllProfileConnections(profile.id);
|
||||
await scope.profileRegistry.remove(profile.id);
|
||||
await scope.storage.clearProfileLastUsed(profile.id);
|
||||
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
|
||||
@@ -263,14 +260,7 @@ Future<bool> confirmAndSignOutPlexAccount(BuildContext context, {required String
|
||||
await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds);
|
||||
}
|
||||
|
||||
await removePlexAccountConnectionAndCleanup(
|
||||
account: account,
|
||||
profileConnections: scope.profileConnections,
|
||||
connections: scope.connections,
|
||||
storage: scope.storage,
|
||||
serverManager: scope.serverManager,
|
||||
plannedRemoval: removal,
|
||||
);
|
||||
await scope.cleanup.removePlexAccountConnection(account, plannedRemoval: removal);
|
||||
for (final profileId in removal.removedVirtualProfileIds) {
|
||||
await scope.database.deleteSyncRulesForProfile(profileId);
|
||||
await scope.database.deleteWatchActionsForProfile(profileId);
|
||||
|
||||
@@ -55,12 +55,6 @@ class AddConnectionScreen extends StatelessWidget {
|
||||
),
|
||||
];
|
||||
final tokensRef = tokens(context);
|
||||
// M3E connected-group geometry: large outer corners, small inner corners,
|
||||
// hairline gaps.
|
||||
BorderRadius radiiFor(int i) => BorderRadius.vertical(
|
||||
top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
);
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(
|
||||
scoped
|
||||
@@ -75,7 +69,7 @@ class AddConnectionScreen extends StatelessWidget {
|
||||
for (var i = 0; i < options.length; i++) ...[
|
||||
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
||||
_BackendCard(
|
||||
borderRadius: radiiFor(i),
|
||||
borderRadius: groupItemRadii(context, i, options.length),
|
||||
leading: options[i].backend != null
|
||||
? BackendBadge(backend: options[i].backend!, size: 28)
|
||||
: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
|
||||
|
||||
@@ -342,13 +342,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
_discoveredServerFocusNodes[_localServers.last.id]?.requestFocus();
|
||||
}
|
||||
|
||||
List<String> _enteredUrls() {
|
||||
return _urlController.text
|
||||
.split(RegExp(r'[\n,]+'))
|
||||
.map((url) => url.trim())
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
List<String> _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text);
|
||||
|
||||
/// Shared persistence path for both username/password and Quick Connect:
|
||||
/// atomically provision the optional first-run profile, connection, and
|
||||
@@ -642,12 +636,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
|
||||
if (_localServers.isEmpty) return const [];
|
||||
final tokensRef = tokens(context);
|
||||
// M3E connected-group geometry: large outer corners, small inner corners,
|
||||
// hairline gaps between tiles.
|
||||
BorderRadius radiiFor(int i) => BorderRadius.vertical(
|
||||
top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
||||
);
|
||||
return [
|
||||
const SizedBox(height: 16),
|
||||
Text(t.addServer.localServers, style: theme.textTheme.titleSmall),
|
||||
@@ -656,7 +644,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
||||
_DiscoveredJellyfinServerTile(
|
||||
server: server,
|
||||
borderRadius: radiiFor(i),
|
||||
borderRadius: groupItemRadii(context, i, _localServers.length),
|
||||
focusNode: _discoveredServerFocusNodes[server.id],
|
||||
onNavigateUp: () {
|
||||
final index = _localServers.indexOf(server);
|
||||
|
||||
@@ -86,12 +86,11 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
|
||||
// to the profile, remove it again so a cancelled attach doesn't
|
||||
// leave a global account behind.
|
||||
if (!registration.existedBefore) {
|
||||
await removePlexAccountConnectionAndCleanup(
|
||||
account: connection,
|
||||
await ProfileConnectionCleanup(
|
||||
profileConnections: pcRegistry,
|
||||
connections: connRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
).removePlexAccountConnection(connection);
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(false);
|
||||
return true;
|
||||
|
||||
@@ -212,14 +212,12 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
Widget _themeSelector() {
|
||||
return Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, _) {
|
||||
return SettingSelectionTile<settings.ThemeMode, settings.ThemeMode>(
|
||||
return SettingSelectionTile<settings.ThemeMode>(
|
||||
pref: SettingsService.themeMode,
|
||||
icon: themeProvider.themeModeIcon,
|
||||
title: t.settings.theme,
|
||||
subtitleBuilder: themeModeLabel,
|
||||
options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(),
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -293,7 +291,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode, ViewMode>(
|
||||
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode>(
|
||||
pref: SettingsService.viewMode,
|
||||
icon: Symbols.view_list_rounded,
|
||||
title: t.settings.viewMode,
|
||||
@@ -301,11 +299,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)),
|
||||
ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
Widget _episodePosterModeSelector() => SettingSegmentedTile<EpisodePosterMode, EpisodePosterMode>(
|
||||
Widget _episodePosterModeSelector() => SettingSegmentedTile<EpisodePosterMode>(
|
||||
pref: SettingsService.episodePosterMode,
|
||||
icon: Symbols.image_rounded,
|
||||
title: t.settings.episodePosterMode,
|
||||
@@ -314,11 +310,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)),
|
||||
ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
Widget _continueWatchingActionSelector() => SettingSegmentedTile<ContinueWatchingAction, ContinueWatchingAction>(
|
||||
Widget _continueWatchingActionSelector() => SettingSegmentedTile<ContinueWatchingAction>(
|
||||
pref: SettingsService.continueWatchingAction,
|
||||
icon: Symbols.play_circle_rounded,
|
||||
title: t.settings.continueWatchingAction,
|
||||
@@ -326,11 +320,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)),
|
||||
ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
Widget _episodeActionSelector() => SettingSegmentedTile<EpisodeAction, EpisodeAction>(
|
||||
Widget _episodeActionSelector() => SettingSegmentedTile<EpisodeAction>(
|
||||
pref: SettingsService.episodeAction,
|
||||
icon: Symbols.tv_rounded,
|
||||
title: t.settings.episodeAction,
|
||||
@@ -338,8 +330,6 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)),
|
||||
ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
// Sections offered as a startup destination, in display order. Live TV is
|
||||
@@ -353,14 +343,12 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
|
||||
String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel();
|
||||
|
||||
Widget _startupSectionSelector() => SettingSelectionTile<NavigationTabId, NavigationTabId>(
|
||||
Widget _startupSectionSelector() => SettingSelectionTile<NavigationTabId>(
|
||||
pref: SettingsService.startupSection,
|
||||
icon: Symbols.start_rounded,
|
||||
title: t.settings.startupSection,
|
||||
subtitleBuilder: _startupSectionLabel,
|
||||
options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(),
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
);
|
||||
|
||||
String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) {
|
||||
@@ -369,32 +357,29 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
||||
VisualEffectsSetting.reduced => t.settings.visualEffectsReduced,
|
||||
};
|
||||
|
||||
Widget _visualEffectsSelector(BuildContext context) =>
|
||||
SettingSelectionTile<VisualEffectsSetting, VisualEffectsSetting>(
|
||||
pref: SettingsService.visualEffects,
|
||||
icon: Symbols.animation_rounded,
|
||||
title: t.settings.visualEffects,
|
||||
subtitleBuilder: _visualEffectsLabel,
|
||||
options: [
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.auto,
|
||||
title: t.settings.visualEffectsAuto,
|
||||
subtitle: t.settings.visualEffectsAutoDescription,
|
||||
),
|
||||
DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull),
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.reduced,
|
||||
title: t.settings.visualEffectsReduced,
|
||||
subtitle: t.settings.visualEffectsReducedDescription,
|
||||
),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
onAfterWrite: (value) {
|
||||
DevicePerformance.setOverrideSync(value);
|
||||
_restartApp(context);
|
||||
},
|
||||
);
|
||||
Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile<VisualEffectsSetting>(
|
||||
pref: SettingsService.visualEffects,
|
||||
icon: Symbols.animation_rounded,
|
||||
title: t.settings.visualEffects,
|
||||
subtitleBuilder: _visualEffectsLabel,
|
||||
options: [
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.auto,
|
||||
title: t.settings.visualEffectsAuto,
|
||||
subtitle: t.settings.visualEffectsAutoDescription,
|
||||
),
|
||||
DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull),
|
||||
DialogOption(
|
||||
value: VisualEffectsSetting.reduced,
|
||||
title: t.settings.visualEffectsReduced,
|
||||
subtitle: t.settings.visualEffectsReducedDescription,
|
||||
),
|
||||
],
|
||||
onAfterWrite: (value) {
|
||||
DevicePerformance.setOverrideSync(value);
|
||||
_restartApp(context);
|
||||
},
|
||||
);
|
||||
|
||||
String _getLanguageDisplayName(AppLocale locale) {
|
||||
switch (locale) {
|
||||
|
||||
@@ -69,13 +69,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _enteredUrls() {
|
||||
return _urlsController.text
|
||||
.split(RegExp(r'[\n,]+'))
|
||||
.map((url) => url.trim())
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
List<String> _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlsController.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../i18n/strings.g.dart';
|
||||
import '../../models/hotkey_model.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../services/shader_service.dart';
|
||||
import '../../services/shortcut_action.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
@@ -26,9 +26,7 @@ class KeyboardShortcutsScreen extends StatelessWidget {
|
||||
listenable: keyboardService,
|
||||
builder: (context, _) {
|
||||
final hotkeys = keyboardService.hotkeys;
|
||||
final actions = hotkeys.keys
|
||||
.where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported)
|
||||
.toList();
|
||||
final actions = hotkeys.keys.where((action) => ShortcutAction.fromId(action)?.isSupported ?? true).toList();
|
||||
return FocusedScrollScaffold(
|
||||
title: Text(t.settings.keyboardShortcuts),
|
||||
slivers: [
|
||||
|
||||
@@ -266,7 +266,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
],
|
||||
);
|
||||
|
||||
Widget _playerBackendSelector() => SettingSegmentedTile<bool, bool>(
|
||||
Widget _playerBackendSelector() => SettingSegmentedTile<bool>(
|
||||
pref: SettingsService.useExoPlayer,
|
||||
icon: Symbols.play_circle_rounded,
|
||||
title: t.settings.playerBackend,
|
||||
@@ -274,8 +274,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
ButtonSegment(value: true, label: Text(t.settings.exoPlayer)),
|
||||
ButtonSegment(value: false, label: Text(t.settings.mpv)),
|
||||
],
|
||||
decode: (s) => s,
|
||||
encode: (s) => s,
|
||||
);
|
||||
|
||||
Widget _externalPlayerTile() => SettingsBuilder(
|
||||
@@ -391,7 +389,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
subtitle: t.settings.tunneledPlaybackDescription,
|
||||
);
|
||||
|
||||
Widget _dvConversionModeTile() => SettingSelectionTile<DvConversionModePreference, DvConversionModePreference>(
|
||||
Widget _dvConversionModeTile() => SettingSelectionTile<DvConversionModePreference>(
|
||||
pref: SettingsService.dvConversionMode,
|
||||
icon: Symbols.hdr_strong_rounded,
|
||||
title: t.settings.dvConversionMode,
|
||||
@@ -399,8 +397,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
options: DvConversionModePreference.values
|
||||
.map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m)))
|
||||
.toList(),
|
||||
decode: (m) => m,
|
||||
encode: (m) => m,
|
||||
);
|
||||
|
||||
String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) {
|
||||
@@ -412,7 +408,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
|
||||
Widget _bufferSizeTile() {
|
||||
final bufferOptions = const [0, 64, 128, 256, 512, 1024];
|
||||
return SettingSelectionTile<int, int>(
|
||||
return SettingSelectionTile<int>(
|
||||
pref: SettingsService.bufferSize,
|
||||
icon: Symbols.memory_rounded,
|
||||
title: t.settings.bufferSize,
|
||||
@@ -420,8 +416,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
options: bufferOptions
|
||||
.map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB'))
|
||||
.toList(),
|
||||
decode: (s) => s,
|
||||
encode: (s) => s,
|
||||
onAfterWrite: (value) async {
|
||||
if (Platform.isAndroid && value > 0) {
|
||||
final heapMB = await PlayerAndroid.getHeapSize();
|
||||
@@ -433,7 +427,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultQualityTile() => SettingSelectionTile<TranscodeQualityPreset, TranscodeQualityPreset>(
|
||||
Widget _defaultQualityTile() => SettingSelectionTile<TranscodeQualityPreset>(
|
||||
pref: SettingsService.defaultQualityPreset,
|
||||
icon: Symbols.high_quality_rounded,
|
||||
title: t.settings.defaultQualityTitle,
|
||||
@@ -441,18 +435,14 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
||||
options: TranscodeQualityPreset.displayOrder
|
||||
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
|
||||
.toList(),
|
||||
decode: (p) => p,
|
||||
encode: (p) => p,
|
||||
);
|
||||
|
||||
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset, AudioQualityPreset>(
|
||||
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset>(
|
||||
pref: SettingsService.musicQualityPreset,
|
||||
icon: Symbols.music_note_rounded,
|
||||
title: t.settings.musicQualityTitle,
|
||||
subtitleBuilder: _musicQualityLabel,
|
||||
options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(),
|
||||
decode: (p) => p,
|
||||
encode: (p) => p,
|
||||
);
|
||||
|
||||
String _musicQualityLabel(AudioQualityPreset preset) =>
|
||||
|
||||
@@ -3,9 +3,8 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
import '../../providers/seerr_account_provider.dart';
|
||||
import '../../providers/trackers_provider.dart';
|
||||
import '../../providers/trakt_account_provider.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/catalog_source_logo.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
@@ -13,8 +12,7 @@ import '../../widgets/focusable_list_tile.dart';
|
||||
import '../../widgets/settings_section.dart';
|
||||
import 'seerr_connect_screen.dart';
|
||||
import 'seerr_settings_screen.dart';
|
||||
import 'tracker_settings_screen.dart';
|
||||
import 'trakt_settings_screen.dart';
|
||||
import 'tracker_service_info.dart';
|
||||
|
||||
/// Unified hub for all connected services: the watch-progress trackers
|
||||
/// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each
|
||||
@@ -38,7 +36,7 @@ class ServicesSettingsScreen extends StatelessWidget {
|
||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl(), _seerr()]),
|
||||
SettingsGroup(children: [for (final info in TrackerServiceInfo.all) _TrackerHubRow(info), _seerr()]),
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
@@ -46,78 +44,9 @@ class ServicesSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _trakt() => Consumer<TraktAccountProvider>(
|
||||
builder: (context, account, _) => _ServiceHubRow(
|
||||
leading: const CatalogSourceLogo.asset('assets/trakt_circlemark.svg', size: 24),
|
||||
title: t.trakt.title,
|
||||
username: account.isConnected ? account.username : null,
|
||||
onTap: () {
|
||||
if (account.isConnected) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen()));
|
||||
} else {
|
||||
startTraktConnection(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _mal() => Consumer<TrackersProvider>(
|
||||
builder: (context, account, _) => _ServiceHubRow(
|
||||
leading: const CatalogSourceLogo.asset('assets/mal_mark.svg', size: 24),
|
||||
title: t.services.names.mal,
|
||||
username: account.isMalConnected ? account.malUsername : null,
|
||||
onTap: () {
|
||||
if (account.isMalConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())),
|
||||
);
|
||||
} else {
|
||||
startMalConnection(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _anilist() => Consumer<TrackersProvider>(
|
||||
builder: (context, account, _) => _ServiceHubRow(
|
||||
leading: const CatalogSourceLogo.asset('assets/anilist_mark.svg', size: 24),
|
||||
title: t.services.names.anilist,
|
||||
username: account.isAnilistConnected ? account.anilistUsername : null,
|
||||
onTap: () {
|
||||
if (account.isAnilistConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())),
|
||||
);
|
||||
} else {
|
||||
startAnilistConnection(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _simkl() => Consumer<TrackersProvider>(
|
||||
builder: (context, account, _) => _ServiceHubRow(
|
||||
leading: const CatalogSourceLogo.asset('assets/simkl_mark.svg', size: 24),
|
||||
title: t.services.names.simkl,
|
||||
username: account.isSimklConnected ? account.simklUsername : null,
|
||||
onTap: () {
|
||||
if (account.isSimklConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())),
|
||||
);
|
||||
} else {
|
||||
startSimklConnection(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _seerr() => Consumer<SeerrAccountProvider>(
|
||||
builder: (context, account, _) => _ServiceHubRow(
|
||||
leading: const CatalogSourceLogo.asset('assets/seerr_mark.svg', size: 24),
|
||||
leading: const CatalogSourceLogo(CatalogSourceId.seerr, size: 24),
|
||||
title: t.services.names.seerr,
|
||||
username: account.isConnected ? account.displayName : null,
|
||||
onTap: () {
|
||||
@@ -132,6 +61,31 @@ class ServicesSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Hub row for a watch tracker. Owns the `watch` on that service's account
|
||||
/// provider so only this row rebuilds when the connection state changes.
|
||||
class _TrackerHubRow extends StatelessWidget {
|
||||
final TrackerServiceInfo info;
|
||||
|
||||
const _TrackerHubRow(this.info);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connected = info.isConnected(context);
|
||||
return _ServiceHubRow(
|
||||
leading: CatalogSourceLogo(info.logoSource, size: 24),
|
||||
title: info.displayName,
|
||||
username: connected ? info.username(context) : null,
|
||||
onTap: () {
|
||||
if (connected) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => info.buildSettingsScreen()));
|
||||
} else {
|
||||
info.startConnection(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceHubRow extends StatelessWidget {
|
||||
final Widget leading;
|
||||
final String title;
|
||||
|
||||
@@ -26,12 +26,9 @@ import '../../services/saf_storage_service.dart';
|
||||
import '../../services/settings_export_service.dart';
|
||||
import '../../providers/theme_provider.dart';
|
||||
import '../../providers/seerr_account_provider.dart';
|
||||
import '../../providers/trackers_provider.dart';
|
||||
import '../../providers/trakt_account_provider.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart' as settings;
|
||||
import '../../services/update_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -55,6 +52,7 @@ import 'playback_settings_screen.dart';
|
||||
import '../profile/profile_switch_screen.dart';
|
||||
import 'services_settings_screen.dart';
|
||||
import 'settings_utils.dart';
|
||||
import 'tracker_service_info.dart';
|
||||
import '../../widgets/loading_indicator_box.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -263,13 +261,12 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
}
|
||||
|
||||
Widget _buildServicesTile() {
|
||||
return Consumer3<TraktAccountProvider, TrackersProvider, SeerrAccountProvider>(
|
||||
builder: (context, trakt, trackers, seerr, _) {
|
||||
// The tracker account providers are watched through [TrackerServiceInfo].
|
||||
return Consumer<SeerrAccountProvider>(
|
||||
builder: (context, seerr, _) {
|
||||
final connectedNames = <String>[
|
||||
if (trakt.isConnected) t.trakt.title,
|
||||
if (trackers.isMalConnected) t.services.names.mal,
|
||||
if (trackers.isAnilistConnected) t.services.names.anilist,
|
||||
if (trackers.isSimklConnected) t.services.names.simkl,
|
||||
for (final info in TrackerServiceInfo.all)
|
||||
if (info.isConnected(context)) info.displayName,
|
||||
if (seerr.isConnected) t.services.names.seerr,
|
||||
];
|
||||
final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · ');
|
||||
@@ -614,65 +611,50 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
}
|
||||
|
||||
Future<bool> _selectDownloadLocation() async {
|
||||
try {
|
||||
String? selectedPath;
|
||||
String pathType = 'file';
|
||||
final changed = await guardSettingsOperation<bool, DownloadStorageException>(
|
||||
context,
|
||||
operation: 'Download directory selection',
|
||||
body: () async {
|
||||
String? selectedPath;
|
||||
String pathType = 'file';
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final safStorage = SafStorageService.instance;
|
||||
if (!safStorage.supportsDirectoryPicker) {
|
||||
showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable);
|
||||
return false;
|
||||
if (Platform.isAndroid) {
|
||||
final safStorage = SafStorageService.instance;
|
||||
if (!safStorage.supportsDirectoryPicker) {
|
||||
showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable);
|
||||
return false;
|
||||
}
|
||||
selectedPath = await safStorage.pickDirectory();
|
||||
if (!mounted) return false;
|
||||
if (selectedPath != null) pathType = 'saf';
|
||||
} else {
|
||||
selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder);
|
||||
if (!mounted) return false;
|
||||
}
|
||||
selectedPath = await safStorage.pickDirectory();
|
||||
if (!mounted) return false;
|
||||
if (selectedPath != null) pathType = 'saf';
|
||||
} else {
|
||||
selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder);
|
||||
if (!mounted) return false;
|
||||
}
|
||||
if (selectedPath == null) return false;
|
||||
if (selectedPath == null) return false;
|
||||
|
||||
if (pathType == 'file') {
|
||||
final dir = Directory(selectedPath);
|
||||
final isWritable =
|
||||
await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(dir);
|
||||
if (!mounted) return false;
|
||||
if (!isWritable) {
|
||||
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
||||
return false;
|
||||
if (pathType == 'file') {
|
||||
final dir = Directory(selectedPath);
|
||||
final writableChecker =
|
||||
widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable;
|
||||
final isWritable = await writableChecker(dir);
|
||||
if (!mounted) return false;
|
||||
if (!isWritable) {
|
||||
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.read<DownloadProvider>().setDownloadLocation(path: selectedPath, pathType: pathType);
|
||||
if (!mounted) return false;
|
||||
await context.read<DownloadProvider>().setDownloadLocation(path: selectedPath, pathType: pathType);
|
||||
if (!mounted) return false;
|
||||
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
|
||||
setState(() {});
|
||||
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
||||
return true;
|
||||
} on DownloadStorageException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
}
|
||||
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
|
||||
setState(() {});
|
||||
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
return changed ?? false;
|
||||
}
|
||||
|
||||
Future<void> _resetDownloadLocation() async {
|
||||
@@ -720,29 +702,15 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
}
|
||||
|
||||
Future<void> _handleExportSettings() async {
|
||||
try {
|
||||
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
|
||||
if (!mounted || path == null) return;
|
||||
showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
|
||||
} on SettingsExportException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
await guardSettingsOperation<void, SettingsExportException>(
|
||||
context,
|
||||
operation: 'Settings export',
|
||||
body: () async {
|
||||
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
|
||||
if (!mounted || path == null) return;
|
||||
showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showImportSettingsDialog() async {
|
||||
@@ -757,51 +725,41 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
||||
}
|
||||
|
||||
Future<void> _handleImportSettings() async {
|
||||
try {
|
||||
final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)();
|
||||
if (!mounted) return;
|
||||
if (result == null) return; // user cancelled file picker
|
||||
await guardSettingsOperation<void, SettingsExportException>(
|
||||
context,
|
||||
operation: 'Settings import',
|
||||
body: () async {
|
||||
// The two typed import failures carry their own message, so they are
|
||||
// handled here instead of falling through to the generic guard.
|
||||
try {
|
||||
final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)();
|
||||
if (!mounted) return;
|
||||
if (result == null) return; // user cancelled file picker
|
||||
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
||||
final librariesProvider = context.read<LibrariesProvider>();
|
||||
final themeProvider = context.read<ThemeProvider>();
|
||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
||||
final librariesProvider = context.read<LibrariesProvider>();
|
||||
|
||||
// Import wrote directly to SharedPreferences, bypassing `write`. Push
|
||||
// fresh values into active listenables before providers re-read settings.
|
||||
_settingsService.refreshListenables();
|
||||
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
|
||||
await Future.wait([
|
||||
themeProvider.reload(),
|
||||
hiddenLibrariesProvider.refresh(),
|
||||
if (_keyboardService != null) _keyboardService!.refreshFromStorage(),
|
||||
]);
|
||||
unawaited(librariesProvider.refresh());
|
||||
// Import wrote directly to SharedPreferences, bypassing `write`. Push
|
||||
// fresh values into active listenables before providers re-read settings.
|
||||
_settingsService.refreshListenables();
|
||||
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
|
||||
await Future.wait([
|
||||
themeProvider.reload(),
|
||||
hiddenLibrariesProvider.refresh(),
|
||||
if (_keyboardService != null) _keyboardService!.refreshFromStorage(),
|
||||
]);
|
||||
unawaited(librariesProvider.refresh());
|
||||
|
||||
if (!mounted) return;
|
||||
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
||||
} on NoUserSignedInException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
||||
} on InvalidExportFileException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile);
|
||||
} on SettingsExportException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
||||
} on PlatformException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
if (!mounted) {
|
||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
||||
return;
|
||||
}
|
||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
if (!mounted) return;
|
||||
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
||||
} on NoUserSignedInException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
||||
} on InvalidExportFileException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdates() async {
|
||||
|
||||
@@ -56,6 +56,32 @@ void showSettingsFailure(
|
||||
if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed);
|
||||
}
|
||||
|
||||
/// Runs [body] and reports the recoverable failures that every settings
|
||||
/// file/platform operation shares — [PlatformException], [FileSystemException]
|
||||
/// and the site-specific domain exception [E] — through [showSettingsFailure].
|
||||
/// Any other exception type is rethrown so programming errors are not swallowed.
|
||||
///
|
||||
/// [context] is resolved before [body] starts, so a failure that lands after the
|
||||
/// caller was disposed is still logged; only the snackbar is skipped. Returns
|
||||
/// `null` when the operation failed.
|
||||
Future<T?> guardSettingsOperation<T, E extends Object>(
|
||||
BuildContext context, {
|
||||
required String operation,
|
||||
required Future<T> Function() body,
|
||||
}) async {
|
||||
try {
|
||||
return await body();
|
||||
} on Object catch (error, stackTrace) {
|
||||
if (error is! E && error is! PlatformException && error is! FileSystemException) rethrow;
|
||||
if (context.mounted) {
|
||||
showSettingsFailure(context, operation: operation, error: error, stackTrace: stackTrace);
|
||||
} else {
|
||||
appLogger.e('$operation failed', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _showSettingsInputDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
|
||||
@@ -48,18 +48,16 @@ class SubtitleStylingScreen extends StatelessWidget {
|
||||
SettingsGroup(
|
||||
title: t.subtitlingStyling.text,
|
||||
children: [
|
||||
SettingSelectionTile<SubAssOverride, SubAssOverride>(
|
||||
SettingSelectionTile<SubAssOverride>(
|
||||
pref: SettingsService.subAssOverride,
|
||||
icon: Symbols.subtitles_rounded,
|
||||
title: t.subtitlingStyling.assOverride,
|
||||
subtitleBuilder: _assOverrideLabel,
|
||||
options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(),
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
),
|
||||
// iOS/tvOS avfoundation VO: screen vs video-resolution basis.
|
||||
if (Platform.isIOS)
|
||||
SettingSelectionTile<SubtitleRenderResolution, SubtitleRenderResolution>(
|
||||
SettingSelectionTile<SubtitleRenderResolution>(
|
||||
pref: SettingsService.subtitleRenderResolution,
|
||||
icon: Symbols.aspect_ratio_rounded,
|
||||
title: t.subtitlingStyling.renderResolution,
|
||||
@@ -68,13 +66,11 @@ class SubtitleStylingScreen extends StatelessWidget {
|
||||
SubtitleRenderResolution.screen,
|
||||
SubtitleRenderResolution.video,
|
||||
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
),
|
||||
// Android libass overlay: full or a fractional render scale (perf knob for
|
||||
// render-bound low-end TVs; heavy/animated signs raster faster at < 1).
|
||||
if (Platform.isAndroid)
|
||||
SettingSelectionTile<SubtitleRenderResolution, SubtitleRenderResolution>(
|
||||
SettingSelectionTile<SubtitleRenderResolution>(
|
||||
pref: SettingsService.subtitleRenderResolution,
|
||||
icon: Symbols.aspect_ratio_rounded,
|
||||
title: t.subtitlingStyling.renderResolution,
|
||||
@@ -86,8 +82,6 @@ class SubtitleStylingScreen extends StatelessWidget {
|
||||
SubtitleRenderResolution.third,
|
||||
SubtitleRenderResolution.quarter,
|
||||
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
),
|
||||
SettingNumberTile(
|
||||
pref: SettingsService.subtitleFontSize,
|
||||
|
||||
@@ -86,7 +86,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget {
|
||||
),
|
||||
SettingsGroup(
|
||||
children: [
|
||||
SettingSegmentedTile<TrackerLibraryFilterMode, TrackerLibraryFilterMode>(
|
||||
SettingSegmentedTile<TrackerLibraryFilterMode>(
|
||||
pref: modePref,
|
||||
icon: Symbols.filter_list_rounded,
|
||||
title: t.services.libraryFilter.mode,
|
||||
@@ -100,8 +100,6 @@ class TrackerLibraryFilterScreen extends StatelessWidget {
|
||||
label: Text(t.services.libraryFilter.modeWhitelist),
|
||||
),
|
||||
],
|
||||
decode: (v) => v,
|
||||
encode: (v) => v,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/catalog/catalog_item.dart';
|
||||
import '../../providers/trackers_provider.dart';
|
||||
import '../../providers/trakt_account_provider.dart';
|
||||
import '../../services/trackers/anilist/anilist_tracker.dart';
|
||||
import '../../services/trackers/mal/mal_tracker.dart';
|
||||
import '../../services/trackers/simkl/simkl_tracker.dart';
|
||||
import '../../services/trackers/tracker.dart';
|
||||
import '../../services/trackers/tracker_constants.dart';
|
||||
import '../../services/trakt/trakt_scrobble_service.dart';
|
||||
import 'tracker_settings_screen.dart';
|
||||
import 'trakt_settings_screen.dart';
|
||||
|
||||
/// One watch tracker, described once for every place that lists services: the
|
||||
/// services hub, the rating sheet, and the settings summary line.
|
||||
///
|
||||
/// [isConnected] and [username] take a [BuildContext] because each service
|
||||
/// keeps its account state on a different provider; they read it with `watch`,
|
||||
/// so the calling element rebuilds exactly like the per-service `Consumer`
|
||||
/// these entries replaced.
|
||||
class TrackerServiceInfo {
|
||||
final TrackerService service;
|
||||
final String displayName;
|
||||
|
||||
/// Which brand mark to draw; the asset path itself lives only in
|
||||
/// `CatalogSourceLogo`.
|
||||
final CatalogSourceId logoSource;
|
||||
|
||||
final TrackerRatingSource ratingSource;
|
||||
final bool Function(BuildContext) isConnected;
|
||||
final String? Function(BuildContext) username;
|
||||
final Future<void> Function(BuildContext) startConnection;
|
||||
final Widget Function() buildSettingsScreen;
|
||||
|
||||
const TrackerServiceInfo({
|
||||
required this.service,
|
||||
required this.displayName,
|
||||
required this.logoSource,
|
||||
required this.ratingSource,
|
||||
required this.isConnected,
|
||||
required this.username,
|
||||
required this.startConnection,
|
||||
required this.buildSettingsScreen,
|
||||
});
|
||||
|
||||
/// Entry for a service that shares [TrackerSettingsScreen]: [config] already
|
||||
/// carries the name and the [TrackersProvider] accessors.
|
||||
TrackerServiceInfo.shared(
|
||||
TrackerConfig config, {
|
||||
required this.logoSource,
|
||||
required this.ratingSource,
|
||||
required this.startConnection,
|
||||
}) : service = config.service,
|
||||
displayName = config.displayName,
|
||||
isConnected = ((context) => config.isConnected(context.watch<TrackersProvider>())),
|
||||
username = ((context) => config.username(context.watch<TrackersProvider>())),
|
||||
buildSettingsScreen = (() => TrackerSettingsScreen(config: config));
|
||||
|
||||
/// Display order shared by every list. Built per call because [displayName]
|
||||
/// reads the active locale.
|
||||
static List<TrackerServiceInfo> get all => [
|
||||
TrackerServiceInfo(
|
||||
service: TrackerService.trakt,
|
||||
displayName: t.trakt.title,
|
||||
logoSource: CatalogSourceId.trakt,
|
||||
ratingSource: TraktScrobbleService.instance,
|
||||
isConnected: (context) => context.watch<TraktAccountProvider>().isConnected,
|
||||
username: (context) => context.watch<TraktAccountProvider>().username,
|
||||
startConnection: startTraktConnection,
|
||||
buildSettingsScreen: () => const TraktSettingsScreen(),
|
||||
),
|
||||
TrackerServiceInfo.shared(
|
||||
TrackerConfig.mal(),
|
||||
logoSource: CatalogSourceId.mal,
|
||||
ratingSource: MalTracker.instance,
|
||||
startConnection: startMalConnection,
|
||||
),
|
||||
TrackerServiceInfo.shared(
|
||||
TrackerConfig.anilist(),
|
||||
logoSource: CatalogSourceId.anilist,
|
||||
ratingSource: AnilistTracker.instance,
|
||||
startConnection: startAnilistConnection,
|
||||
),
|
||||
TrackerServiceInfo.shared(
|
||||
TrackerConfig.simkl(),
|
||||
logoSource: CatalogSourceId.simkl,
|
||||
ratingSource: SimklTracker.instance,
|
||||
startConnection: startSimklConnection,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -67,7 +67,6 @@ class TrackerConfig {
|
||||
final String displayName;
|
||||
final bool Function(TrackersProvider) isConnected;
|
||||
final String? Function(TrackersProvider) username;
|
||||
final Pref<bool> scrobblePref;
|
||||
final Future<void> Function(bool) onScrobbleChanged;
|
||||
final Future<void> Function(TrackersProvider) disconnect;
|
||||
|
||||
@@ -76,17 +75,17 @@ class TrackerConfig {
|
||||
required this.displayName,
|
||||
required this.isConnected,
|
||||
required this.username,
|
||||
required this.scrobblePref,
|
||||
required this.onScrobbleChanged,
|
||||
required this.disconnect,
|
||||
});
|
||||
|
||||
Pref<bool> get scrobblePref => SettingsService.scrobblePref(service);
|
||||
|
||||
static TrackerConfig mal() => TrackerConfig(
|
||||
service: TrackerService.mal,
|
||||
displayName: t.services.names.mal,
|
||||
isConnected: (a) => a.isMalConnected,
|
||||
username: (a) => a.malUsername,
|
||||
scrobblePref: SettingsService.enableMalScrobble,
|
||||
onScrobbleChanged: MalTracker.instance.setEnabled,
|
||||
disconnect: (a) => a.disconnectMal(),
|
||||
);
|
||||
@@ -96,7 +95,6 @@ class TrackerConfig {
|
||||
displayName: t.services.names.anilist,
|
||||
isConnected: (a) => a.isAnilistConnected,
|
||||
username: (a) => a.anilistUsername,
|
||||
scrobblePref: SettingsService.enableAnilistScrobble,
|
||||
onScrobbleChanged: AnilistTracker.instance.setEnabled,
|
||||
disconnect: (a) => a.disconnectAnilist(),
|
||||
);
|
||||
@@ -106,7 +104,6 @@ class TrackerConfig {
|
||||
displayName: t.services.names.simkl,
|
||||
isConnected: (a) => a.isSimklConnected,
|
||||
username: (a) => a.simklUsername,
|
||||
scrobblePref: SettingsService.enableSimklScrobble,
|
||||
onScrobbleChanged: SimklTracker.instance.setEnabled,
|
||||
disconnect: (a) => a.disconnectSimkl(),
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ class TraktSettingsScreen extends StatelessWidget {
|
||||
service: TrackerService.trakt,
|
||||
toggles: [
|
||||
TrackerSettingsToggle(
|
||||
pref: SettingsService.enableTraktScrobble,
|
||||
pref: SettingsService.scrobblePref(TrackerService.trakt),
|
||||
icon: Symbols.auto_timer_rounded,
|
||||
title: t.trakt.scrobble,
|
||||
subtitle: t.trakt.scrobbleDescription,
|
||||
|
||||
@@ -42,7 +42,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
||||
_lastVideoLayoutSize = pendingSize;
|
||||
_lastVideoLayoutPlayer = currentPlayer;
|
||||
_videoFilterManager?.updatePlayerSize(pendingSize);
|
||||
_videoPIPManager?.updatePlayerSize(pendingSize);
|
||||
_updateAmbientLightingOnResize(pendingSize);
|
||||
unawaited(currentPlayer.updateFrame());
|
||||
});
|
||||
|
||||
@@ -44,7 +44,10 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
||||
unawaited(_videoFilterManager!.updateVideoFilter());
|
||||
}
|
||||
|
||||
_videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize);
|
||||
_videoPIPManager ??= VideoPIPManager(
|
||||
player: currentPlayer,
|
||||
playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null,
|
||||
);
|
||||
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
||||
_attachPipStateListener();
|
||||
}
|
||||
@@ -92,7 +95,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
||||
return;
|
||||
}
|
||||
|
||||
final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value;
|
||||
final isInPip = PipService().isPipActive.value;
|
||||
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
|
||||
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
final mediaControlsManager = MediaControlsManager();
|
||||
_mediaControlsManager = mediaControlsManager;
|
||||
|
||||
final mediaControlRouter = VideoPlayerMediaControlRouter(
|
||||
final mediaControlRouter = MediaControlRouter(
|
||||
canControlPlayback: _canControlPlayback,
|
||||
canNavigateMediaItems: _canNavigateMediaItems,
|
||||
onPlay: () {
|
||||
|
||||
@@ -22,6 +22,36 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable ambient lighting for the current video/player geometry.
|
||||
/// Returns false when the aspect ratios cannot be determined yet.
|
||||
Future<bool> _enableAmbientLighting(AmbientLightingService ambientLighting, ShaderProvider shaderProvider) async {
|
||||
// Get video display aspect ratio
|
||||
final dwidth = await player?.getProperty('dwidth');
|
||||
final dheight = await player?.getProperty('dheight');
|
||||
if (dwidth == null || dheight == null) return false;
|
||||
final w = double.tryParse(dwidth);
|
||||
final h = double.tryParse(dheight);
|
||||
if (w == null || h == null || h == 0) return false;
|
||||
final videoAspect = w / h;
|
||||
|
||||
// Get player widget aspect ratio
|
||||
final playerSize = _videoFilterManager?.playerSize;
|
||||
if (playerSize == null || playerSize.height == 0) return false;
|
||||
final outputAspect = playerSize.width / playerSize.height;
|
||||
|
||||
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
||||
if (shaderProvider.isShaderEnabled) {
|
||||
await _shaderService!.applyPreset(ShaderPreset.none);
|
||||
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
||||
}
|
||||
|
||||
// Force contain mode when enabling ambient lighting
|
||||
_videoFilterManager?.resetToContain();
|
||||
|
||||
await ambientLighting.enable(videoAspect, outputAspect);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Restore ambient lighting from persisted setting
|
||||
Future<void> _restoreAmbientLighting() async {
|
||||
if (!mounted) return;
|
||||
@@ -34,27 +64,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
||||
final ambientLighting = _ambientLightingService;
|
||||
if (ambientLighting == null || !ambientLighting.isSupported) return;
|
||||
|
||||
// Same enable logic as _toggleAmbientLighting
|
||||
final dwidth = await player?.getProperty('dwidth');
|
||||
final dheight = await player?.getProperty('dheight');
|
||||
if (dwidth == null || dheight == null) return;
|
||||
final w = double.tryParse(dwidth);
|
||||
final h = double.tryParse(dheight);
|
||||
if (w == null || h == null || h == 0) return;
|
||||
final videoAspect = w / h;
|
||||
|
||||
final playerSize = _videoFilterManager?.playerSize;
|
||||
if (playerSize == null || playerSize.height == 0) return;
|
||||
final outputAspect = playerSize.width / playerSize.height;
|
||||
|
||||
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
||||
if (shaderProvider.isShaderEnabled) {
|
||||
await _shaderService!.applyPreset(ShaderPreset.none);
|
||||
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
||||
}
|
||||
|
||||
_videoFilterManager?.resetToContain();
|
||||
await ambientLighting.enable(videoAspect, outputAspect);
|
||||
if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return;
|
||||
if (mounted) _setPlayerState(() {});
|
||||
}
|
||||
|
||||
@@ -117,30 +127,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
||||
await ambientLighting.disable();
|
||||
unawaited(_videoFilterManager?.updateVideoFilter());
|
||||
} else {
|
||||
// Get video display aspect ratio
|
||||
final dwidth = await player?.getProperty('dwidth');
|
||||
final dheight = await player?.getProperty('dheight');
|
||||
if (dwidth == null || dheight == null) return;
|
||||
final w = double.tryParse(dwidth);
|
||||
final h = double.tryParse(dheight);
|
||||
if (w == null || h == null || h == 0) return;
|
||||
final videoAspect = w / h;
|
||||
|
||||
// Get player widget aspect ratio
|
||||
final playerSize = _videoFilterManager?.playerSize;
|
||||
if (playerSize == null || playerSize.height == 0) return;
|
||||
final outputAspect = playerSize.width / playerSize.height;
|
||||
|
||||
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
||||
if (shaderProvider.isShaderEnabled) {
|
||||
await _shaderService!.applyPreset(ShaderPreset.none);
|
||||
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
||||
}
|
||||
|
||||
// Force contain mode when enabling ambient lighting
|
||||
_videoFilterManager?.resetToContain();
|
||||
|
||||
await ambientLighting.enable(videoAspect, outputAspect);
|
||||
if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return;
|
||||
}
|
||||
|
||||
// Persist ambient lighting state
|
||||
|
||||
@@ -188,85 +188,31 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: PipService().isPipActive,
|
||||
builder: (context, isInPip, child) {
|
||||
final episode = nextEpisode;
|
||||
if (isInPip || !visible || episode == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _VideoPlayerPromptPosition(
|
||||
chromeController: chromeController,
|
||||
child: _VideoPlayerPromptInteractionHold(
|
||||
chromeController: chromeController,
|
||||
focusNodes: [cancelFocusNode, confirmFocusNode],
|
||||
child: _VideoPlayerPromptCard(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
_PlayNextEpisodeHeader(episode: episode),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: cancelFocusNode,
|
||||
onPressed: onCancel,
|
||||
autoScroll: false,
|
||||
onNavigateRight: () => confirmFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
child: OutlinedButton(
|
||||
onPressed: onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: confirmFocusNode,
|
||||
onPressed: onPlayNext,
|
||||
autoScroll: false,
|
||||
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton(
|
||||
onPressed: onPlayNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
if (autoPlayCountdown > 0) ...[
|
||||
Text('$autoPlayCountdown'),
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
||||
] else
|
||||
Text(t.videoControls.playNext),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
final episode = nextEpisode;
|
||||
if (episode == null) return const SizedBox.shrink();
|
||||
return _VideoPlayerPromptShell(
|
||||
visible: visible,
|
||||
chromeController: chromeController,
|
||||
focusNodes: [cancelFocusNode, confirmFocusNode],
|
||||
children: [
|
||||
_PlayNextEpisodeHeader(episode: episode),
|
||||
const SizedBox(height: 12),
|
||||
_VideoPlayerPromptActions(
|
||||
cancelLabel: t.common.cancel,
|
||||
cancelFocusNode: cancelFocusNode,
|
||||
onCancel: onCancel,
|
||||
confirmFocusNode: confirmFocusNode,
|
||||
onConfirm: onPlayNext,
|
||||
confirmChildren: [
|
||||
if (autoPlayCountdown > 0) ...[
|
||||
Text('$autoPlayCountdown'),
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
||||
] else
|
||||
Text(t.videoControls.playNext),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -344,6 +290,49 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
||||
required this.onContinue,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _VideoPlayerPromptShell(
|
||||
visible: visible,
|
||||
chromeController: chromeController,
|
||||
focusNodes: [pauseFocusNode, continueFocusNode],
|
||||
children: [
|
||||
Text(
|
||||
t.videoControls.stillWatching,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.videoControls.pausingIn(seconds: '$countdown'),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_VideoPlayerPromptActions(
|
||||
cancelLabel: t.videoControls.pauseButton,
|
||||
cancelFocusNode: pauseFocusNode,
|
||||
onCancel: onPause,
|
||||
confirmFocusNode: continueFocusNode,
|
||||
onConfirm: onContinue,
|
||||
confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerPromptShell extends StatelessWidget {
|
||||
final bool visible;
|
||||
final PlayerChromeController chromeController;
|
||||
final List<FocusNode> focusNodes;
|
||||
final List<Widget> children;
|
||||
|
||||
const _VideoPlayerPromptShell({
|
||||
required this.visible,
|
||||
required this.chromeController,
|
||||
required this.focusNodes,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
@@ -356,75 +345,9 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
||||
chromeController: chromeController,
|
||||
child: _VideoPlayerPromptInteractionHold(
|
||||
chromeController: chromeController,
|
||||
focusNodes: [pauseFocusNode, continueFocusNode],
|
||||
focusNodes: focusNodes,
|
||||
child: _VideoPlayerPromptCard(
|
||||
child: Column(
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(
|
||||
t.videoControls.stillWatching,
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.videoControls.pausingIn(seconds: '$countdown'),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: pauseFocusNode,
|
||||
onPressed: onPause,
|
||||
autoScroll: false,
|
||||
onNavigateRight: () => continueFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
child: OutlinedButton(
|
||||
onPressed: onPause,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text(t.videoControls.pauseButton),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: continueFocusNode,
|
||||
onPressed: onContinue,
|
||||
autoScroll: false,
|
||||
onNavigateLeft: () => pauseFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton(
|
||||
onPressed: onContinue,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
Text('$countdown'),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.videoControls.continueWatching),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -433,6 +356,72 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerPromptActions extends StatelessWidget {
|
||||
final String cancelLabel;
|
||||
final FocusNode cancelFocusNode;
|
||||
final VoidCallback onCancel;
|
||||
final FocusNode confirmFocusNode;
|
||||
final VoidCallback onConfirm;
|
||||
final List<Widget> confirmChildren;
|
||||
|
||||
const _VideoPlayerPromptActions({
|
||||
required this.cancelLabel,
|
||||
required this.cancelFocusNode,
|
||||
required this.onCancel,
|
||||
required this.confirmFocusNode,
|
||||
required this.onConfirm,
|
||||
required this.confirmChildren,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: cancelFocusNode,
|
||||
onPressed: onCancel,
|
||||
autoScroll: false,
|
||||
onNavigateRight: () => confirmFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
child: OutlinedButton(
|
||||
onPressed: onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text(cancelLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FocusableButton(
|
||||
focusNode: confirmFocusNode,
|
||||
onPressed: onConfirm,
|
||||
autoScroll: false,
|
||||
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
||||
onNavigateUp: () {},
|
||||
onNavigateDown: () {},
|
||||
useBackgroundFocus: true,
|
||||
child: FilledButton(
|
||||
onPressed: onConfirm,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Row(mainAxisAlignment: .center, children: confirmChildren),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPlayerPromptPosition extends StatelessWidget {
|
||||
final PlayerChromeController chromeController;
|
||||
final Widget child;
|
||||
|
||||
@@ -58,6 +58,7 @@ import '../services/playback_source_resolver.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_watch_sync_service.dart';
|
||||
import '../services/display_mode_service.dart';
|
||||
import '../services/media_control_router.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/sleep_timer_service.dart';
|
||||
import '../services/track_manager.dart';
|
||||
@@ -87,7 +88,6 @@ import 'video_player/completion_latch.dart';
|
||||
import 'video_player/frame_rate_matcher.dart';
|
||||
import 'video_player/live_stream_retry.dart';
|
||||
import 'video_player/live_timeline_report.dart';
|
||||
import 'video_player/media_control_router.dart';
|
||||
import 'video_player/wakelock_controller.dart';
|
||||
import 'video_player/live_tv_session_args.dart';
|
||||
import 'video_player/live_tv_session_state.dart';
|
||||
@@ -1368,6 +1368,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
return exitPosition;
|
||||
}
|
||||
|
||||
/// Pause/hide the player, flush stopped progress, restore system UI and
|
||||
/// orientation, then leave the player route. No-op when the route cannot pop.
|
||||
Future<void> _exitPlayerRoute({required bool navigateHome}) async {
|
||||
final navigator = Navigator.of(context);
|
||||
if (!navigator.canPop()) return;
|
||||
|
||||
_isExiting.value = true;
|
||||
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
||||
if (!mounted) return;
|
||||
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
||||
if (!mounted) return;
|
||||
await _restoreSystemUiAndOrientation();
|
||||
if (!mounted) return;
|
||||
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
||||
}
|
||||
|
||||
/// Handle back button press
|
||||
/// For non-host participants in Watch Together, shows leave session confirmation
|
||||
Future<void> _handleBackButton({bool navigateHome = false}) async {
|
||||
@@ -1390,36 +1406,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
if (confirmed && mounted) {
|
||||
await _watchTogetherProvider!.leaveSession();
|
||||
if (mounted) {
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
_isExiting.value = true;
|
||||
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
||||
if (!mounted) return;
|
||||
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
||||
if (!mounted) return;
|
||||
await _restoreSystemUiAndOrientation();
|
||||
if (!mounted) return;
|
||||
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
||||
}
|
||||
}
|
||||
if (mounted) await _exitPlayerRoute(navigateHome: navigateHome);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior for hosts or non-session users
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
_isExiting.value = true;
|
||||
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
||||
if (!mounted) return;
|
||||
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
||||
if (!mounted) return;
|
||||
await _restoreSystemUiAndOrientation();
|
||||
if (!mounted) return;
|
||||
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
||||
}
|
||||
await _exitPlayerRoute(navigateHome: navigateHome);
|
||||
} finally {
|
||||
_isHandlingBack = false;
|
||||
}
|
||||
|
||||
@@ -9,33 +9,40 @@ import '../../profiles/profile_connection_registry.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// Resolves the active profile's Plex identity and primes companion-remote
|
||||
/// crypto with it, returning whether crypto ended up ready.
|
||||
///
|
||||
/// Crypto is an app-level service, not bound to any one widget: everything the
|
||||
/// bootstrap needs is captured up front, so an unmount mid-await must not abort
|
||||
/// work the user asked for. Hence no `context.mounted` guards below.
|
||||
Future<bool> ensureCompanionRemoteCryptoFromContext(BuildContext context) async {
|
||||
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
final identity = await resolveActivePlexIdentity(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
);
|
||||
final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id);
|
||||
return companionRemote.ensureCryptoReady(
|
||||
home,
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: profileConnections,
|
||||
identity: identity,
|
||||
plexHomeForConnection: plexHome.materializePlexHomeForConnection,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> startCompanionRemoteHost(BuildContext context) async {
|
||||
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||
if (companionRemote.isHostServerRunning) return true;
|
||||
|
||||
try {
|
||||
// The host is an app-level service, not bound to this widget: everything
|
||||
// it needs is captured up front, so an unmount mid-await must not abort a
|
||||
// start the user asked for. Hence no `context.mounted` guards below.
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
final activeProfile = context.read<ActiveProfileProvider>();
|
||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||
final plexHome = context.read<PlexHomeService>();
|
||||
final identity = await resolveActivePlexIdentity(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
);
|
||||
final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id);
|
||||
final ok = await companionRemote.ensureCryptoReady(
|
||||
home,
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
profileConnections: profileConnections,
|
||||
identity: identity,
|
||||
plexHomeForConnection: plexHome.materializePlexHomeForConnection,
|
||||
);
|
||||
if (!ok) return false;
|
||||
if (!await ensureCompanionRemoteCryptoFromContext(context)) return false;
|
||||
|
||||
await companionRemote.startHostServer();
|
||||
return companionRemote.isHostServerRunning;
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/async_singleton.dart';
|
||||
import '../utils/device_channel.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
/// User override for the visual-effects tier (stored by SettingsService).
|
||||
@@ -19,11 +21,9 @@ enum VisualEffectsSetting { auto, full, reduced }
|
||||
class DevicePerformance {
|
||||
DevicePerformance._();
|
||||
|
||||
static DevicePerformance? _instance;
|
||||
static Future<void>? _initialization;
|
||||
static final AsyncSingleton<DevicePerformance> _singleton = AsyncSingleton();
|
||||
@visibleForTesting
|
||||
static Future<void>? debugDetectionGate;
|
||||
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
||||
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
|
||||
|
||||
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
|
||||
/// reservations), below 3 GB Shield-class devices (~2.8 GiB).
|
||||
@@ -39,35 +39,13 @@ class DevicePerformance {
|
||||
|
||||
/// Get the singleton, detecting hardware signals on first call.
|
||||
/// [override] is the persisted SettingsService.visualEffects value.
|
||||
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
|
||||
final existing = _instance;
|
||||
if (existing != null) {
|
||||
final initialization = _initialization;
|
||||
if (initialization != null) await initialization;
|
||||
return existing;
|
||||
}
|
||||
|
||||
final instance = DevicePerformance._().._override = override;
|
||||
_instance = instance;
|
||||
final initialization = instance._detect();
|
||||
_initialization = initialization;
|
||||
try {
|
||||
await initialization;
|
||||
} catch (_) {
|
||||
if (identical(_instance, instance)) _instance = null;
|
||||
rethrow;
|
||||
} finally {
|
||||
if (identical(_initialization, initialization)) _initialization = null;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) =>
|
||||
_singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect());
|
||||
|
||||
Future<void> _detect() async {
|
||||
final gate = debugDetectionGate;
|
||||
if (gate != null) await gate;
|
||||
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
||||
try {
|
||||
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
||||
final result = await deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
||||
if (result == null) return;
|
||||
_is64Bit = result['is64Bit'] == true;
|
||||
_isLowRam = result['isLowRamDevice'] == true;
|
||||
@@ -85,7 +63,7 @@ class DevicePerformance {
|
||||
|
||||
/// Total device RAM as reported by the platform, or null off-Android /
|
||||
/// before init. Used to scale memory-watchdog thresholds to the device.
|
||||
static int? get totalMemBytes => _instance?._totalMemBytes;
|
||||
static int? get totalMemBytes => _singleton.instance?._totalMemBytes;
|
||||
|
||||
/// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB),
|
||||
/// independent of the visual-effects override. Use this for decisions tied to
|
||||
@@ -93,11 +71,11 @@ class DevicePerformance {
|
||||
/// boxes lagging a GL subtitle overlay — where a user's effects preference is
|
||||
/// irrelevant. Safe before init (returns false). See [isReduced] for the
|
||||
/// effects-tier gate that the override can force.
|
||||
static bool get isLowEndHardware => _instance?._autoReduced ?? false;
|
||||
static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false;
|
||||
|
||||
/// Primary gate for effect chokepoints. Safe before init (full tier).
|
||||
static bool get isReduced {
|
||||
final instance = _instance;
|
||||
final instance = _singleton.instance;
|
||||
if (instance == null) return false;
|
||||
return switch (instance._override) {
|
||||
VisualEffectsSetting.auto => instance._autoReduced,
|
||||
@@ -112,7 +90,7 @@ class DevicePerformance {
|
||||
/// Update the user override from the settings screen and re-apply the
|
||||
/// budgets that were computed at boot.
|
||||
static void setOverrideSync(VisualEffectsSetting value) {
|
||||
_instance?._override = value;
|
||||
_singleton.instance?._override = value;
|
||||
applyImageCacheBudget();
|
||||
}
|
||||
|
||||
@@ -142,7 +120,7 @@ class DevicePerformance {
|
||||
/// Raw signals are always included (even when the tier is forced) so an
|
||||
/// uploaded log answers "did the reduced tier engage, and why / why not".
|
||||
static String describeSync() {
|
||||
final instance = _instance;
|
||||
final instance = _singleton.instance;
|
||||
if (instance == null) return 'unknown';
|
||||
final tier = isReduced ? 'reduced' : 'full';
|
||||
final signals = <String>[
|
||||
@@ -158,14 +136,13 @@ class DevicePerformance {
|
||||
|
||||
@visibleForTesting
|
||||
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
||||
_initialization = null;
|
||||
debugDetectionGate = null;
|
||||
if (autoReduced == null && override == null) {
|
||||
_instance = null;
|
||||
_singleton.debugReset();
|
||||
return;
|
||||
}
|
||||
_instance ??= DevicePerformance._();
|
||||
if (autoReduced != null) _instance!._autoReduced = autoReduced;
|
||||
if (override != null) _instance!._override = override;
|
||||
final instance = _singleton.instance ?? DevicePerformance._();
|
||||
_singleton.debugReset(instance: instance);
|
||||
if (autoReduced != null) instance._autoReduced = autoReduced;
|
||||
if (override != null) instance._override = override;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1323,46 +1323,23 @@ class DownloadManagerService {
|
||||
);
|
||||
}
|
||||
|
||||
var artworkSettled = !queueItem.downloadArtwork;
|
||||
if (queueItem.downloadArtwork) {
|
||||
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
||||
final chapterArtworkSettled = metadata.serverId == null
|
||||
? false
|
||||
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
||||
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
||||
}
|
||||
|
||||
var subtitlesSettled = !queueItem.downloadSubtitles;
|
||||
if (queueItem.downloadSubtitles) {
|
||||
try {
|
||||
final resolution = await client.resolveDownload(
|
||||
metadata,
|
||||
mediaIndex: record?.mediaIndex ?? 0,
|
||||
mediaSourceId: record?.mediaSourceId,
|
||||
);
|
||||
if (resolution.externalSubtitlesResolved) {
|
||||
subtitlesSettled = await _downloadSubtitles(
|
||||
globalKey,
|
||||
metadata,
|
||||
resolution.externalSubtitles,
|
||||
client,
|
||||
showYear: showYear,
|
||||
);
|
||||
} else {
|
||||
appLogger.d('Subtitle enrichment remains deferred for $globalKey');
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
if (artworkSettled && subtitlesSettled) {
|
||||
final settled = await _runSupplementaryDownloads(
|
||||
globalKey,
|
||||
metadata,
|
||||
client,
|
||||
downloadArtwork: queueItem.downloadArtwork,
|
||||
downloadSubtitles: queueItem.downloadSubtitles,
|
||||
record: record,
|
||||
showYear: showYear,
|
||||
);
|
||||
if (settled.artwork && settled.subtitles) {
|
||||
await _database.removeFromQueue(globalKey);
|
||||
appLogger.i('Deferred supplementary downloads completed for $globalKey');
|
||||
} else {
|
||||
await _database.updateSupplementaryQueueIntent(
|
||||
globalKey,
|
||||
downloadSubtitles: !subtitlesSettled,
|
||||
downloadArtwork: !artworkSettled,
|
||||
downloadSubtitles: !settled.subtitles,
|
||||
downloadArtwork: !settled.artwork,
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -1591,17 +1568,8 @@ class DownloadManagerService {
|
||||
if (client != null) unawaited(_processQueue(client));
|
||||
}
|
||||
|
||||
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) async {
|
||||
if (!downloadsSupported || taskId.isEmpty) return;
|
||||
try {
|
||||
final cancelled = await FileDownloader().cancelTaskWithId(taskId);
|
||||
if (cancelled) {
|
||||
appLogger.d('Cancelled native task $taskId for $globalKey ($reason)');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to cancel native task $taskId for $globalKey ($reason)', error: e);
|
||||
}
|
||||
}
|
||||
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) =>
|
||||
_cancelNativeTaskIds(globalKey, [taskId], reason: reason);
|
||||
|
||||
Future<void> _cancelNativeTasksForGlobalKey(
|
||||
String globalKey, {
|
||||
@@ -1624,16 +1592,7 @@ class DownloadManagerService {
|
||||
appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e);
|
||||
}
|
||||
|
||||
if (taskIds.isEmpty) return;
|
||||
|
||||
try {
|
||||
final cancelled = await FileDownloader().cancelTasksWithIds(taskIds);
|
||||
if (cancelled) {
|
||||
appLogger.d('Cancelled ${taskIds.length} native task(s) for $globalKey ($reason): ${taskIds.join(', ')}');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${taskIds.join(', ')}', error: e);
|
||||
}
|
||||
await _cancelNativeTaskIds(globalKey, taskIds, reason: reason);
|
||||
}
|
||||
|
||||
Future<DownloadedMediaItem?> _downloadForCurrentTaskSession(
|
||||
@@ -2390,36 +2349,20 @@ class DownloadManagerService {
|
||||
try {
|
||||
final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey);
|
||||
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
||||
final showYear = ctx?.showYear;
|
||||
|
||||
if (metadata != null && client != null) {
|
||||
if (downloadArtwork) {
|
||||
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
||||
final chapterArtworkSettled = metadata.serverId == null
|
||||
? false
|
||||
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
||||
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
||||
}
|
||||
if (downloadSubtitles) {
|
||||
var subtitles = ctx?.subtitles;
|
||||
if (subtitles == null) {
|
||||
try {
|
||||
final resolution = await client.resolveDownload(
|
||||
metadata,
|
||||
mediaIndex: existingCheck.mediaIndex,
|
||||
mediaSourceId: existingCheck.mediaSourceId,
|
||||
);
|
||||
if (resolution.externalSubtitlesResolved) {
|
||||
subtitles = resolution.externalSubtitles;
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
if (subtitles != null) {
|
||||
subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear);
|
||||
}
|
||||
}
|
||||
final settled = await _runSupplementaryDownloads(
|
||||
globalKey,
|
||||
metadata,
|
||||
client,
|
||||
downloadArtwork: downloadArtwork,
|
||||
downloadSubtitles: downloadSubtitles,
|
||||
record: existingCheck,
|
||||
showYear: ctx?.showYear,
|
||||
preresolvedSubtitles: ctx?.subtitles,
|
||||
);
|
||||
artworkSettled = settled.artwork;
|
||||
subtitlesSettled = settled.subtitles;
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st);
|
||||
@@ -2516,6 +2459,58 @@ class DownloadManagerService {
|
||||
return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId);
|
||||
}
|
||||
|
||||
/// Best-effort supplementary work for an already-stored video (artwork,
|
||||
/// chapter thumbnails, external subtitles); reports which half settled so the
|
||||
/// caller can do its own queue-row bookkeeping. Shared by the completion path
|
||||
/// and the deferred-repair path: [record] carries the media-source
|
||||
/// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that
|
||||
/// re-resolve, and [showYear] is caller-supplied because the paths differ.
|
||||
Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads(
|
||||
String globalKey,
|
||||
MediaItem metadata,
|
||||
MediaServerClient client, {
|
||||
required bool downloadArtwork,
|
||||
required bool downloadSubtitles,
|
||||
required DownloadedMediaItem? record,
|
||||
required int? showYear,
|
||||
List<DownloadSubtitleSpec>? preresolvedSubtitles,
|
||||
}) async {
|
||||
var artworkSettled = !downloadArtwork;
|
||||
if (downloadArtwork) {
|
||||
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
||||
final chapterArtworkSettled = metadata.serverId == null
|
||||
? false
|
||||
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
||||
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
||||
}
|
||||
|
||||
var subtitlesSettled = !downloadSubtitles;
|
||||
if (downloadSubtitles) {
|
||||
try {
|
||||
var subtitles = preresolvedSubtitles;
|
||||
if (subtitles == null) {
|
||||
final resolution = await client.resolveDownload(
|
||||
metadata,
|
||||
mediaIndex: record?.mediaIndex ?? 0,
|
||||
mediaSourceId: record?.mediaSourceId,
|
||||
);
|
||||
if (resolution.externalSubtitlesResolved) {
|
||||
subtitles = resolution.externalSubtitles;
|
||||
} else {
|
||||
appLogger.d('Subtitle enrichment remains deferred for $globalKey');
|
||||
}
|
||||
}
|
||||
if (subtitles != null) {
|
||||
subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear);
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
return (artwork: artworkSettled, subtitles: subtitlesSettled);
|
||||
}
|
||||
|
||||
Future<bool> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
||||
if (metadata.serverId == null) return false;
|
||||
|
||||
@@ -3266,24 +3261,39 @@ class DownloadManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteMovieStorageDirectory(MediaItem movie) async {
|
||||
/// Delete one media directory and everything under it, on either storage backend.
|
||||
/// [safComponents] and [fileDirectory] are thunks so only the branch that runs
|
||||
/// resolves its path — the file-mode getters create the directory as a side effect.
|
||||
Future<void> _deleteStorageDirectory({
|
||||
required List<String> Function() safComponents,
|
||||
required Future<Directory> Function() fileDirectory,
|
||||
required String label,
|
||||
}) async {
|
||||
if (_storageService.isUsingSaf) {
|
||||
final safBaseUri = _storageService.safBaseUri;
|
||||
if (safBaseUri == null) return;
|
||||
final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie));
|
||||
if (movieDir != null) {
|
||||
await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory');
|
||||
final dir = await _safStorage.getChild(safBaseUri, safComponents());
|
||||
if (dir != null) {
|
||||
await _deleteSafDirRecursive(dir.uri, description: '$label directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final movieDir = await _storageService.getMovieDirectory(movie);
|
||||
if (await movieDir.exists()) {
|
||||
await movieDir.delete(recursive: true);
|
||||
appLogger.i('Deleted movie directory: ${movieDir.path}');
|
||||
final dir = await fileDirectory();
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
appLogger.i('Deleted $label directory: ${dir.path}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteMovieStorageDirectory(MediaItem movie) {
|
||||
return _deleteStorageDirectory(
|
||||
safComponents: () => _storageService.getMovieSafPathComponents(movie),
|
||||
fileDirectory: () => _storageService.getMovieDirectory(movie),
|
||||
label: 'movie',
|
||||
);
|
||||
}
|
||||
|
||||
Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo(
|
||||
MediaItem episode, {
|
||||
required int? showYear,
|
||||
@@ -3330,50 +3340,32 @@ class DownloadManagerService {
|
||||
}
|
||||
|
||||
Future<void> _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async {
|
||||
await _deleteStorageDirectory(
|
||||
safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear),
|
||||
fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear),
|
||||
label: 'season',
|
||||
);
|
||||
|
||||
// Drop the parent show directory too if the deleted season left it empty.
|
||||
if (_storageService.isUsingSaf) {
|
||||
final safBaseUri = _storageService.safBaseUri;
|
||||
if (safBaseUri == null) return;
|
||||
final seasonDir = await _safStorage.getChild(
|
||||
safBaseUri,
|
||||
_storageService.getSeasonSafPathComponents(season, showYear: showYear),
|
||||
);
|
||||
if (seasonDir != null) {
|
||||
await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory');
|
||||
}
|
||||
final showDir = await _safStorage.getChild(
|
||||
safBaseUri,
|
||||
_storageService.getShowSafPathComponents(season, showYear: showYear),
|
||||
);
|
||||
if (showDir != null) {
|
||||
await _deleteEmptySafDirsInOrder([showDir.uri]);
|
||||
}
|
||||
await _deleteEmptySafDirsInOrder([showDir?.uri]);
|
||||
return;
|
||||
}
|
||||
|
||||
final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear);
|
||||
if (await seasonDir.exists()) {
|
||||
await seasonDir.delete(recursive: true);
|
||||
appLogger.i('Deleted season directory: ${seasonDir.path}');
|
||||
}
|
||||
await _cleanupShowDirectory(season, showYear);
|
||||
}
|
||||
|
||||
Future<void> _deleteShowStorageDirectory(MediaItem show) async {
|
||||
if (_storageService.isUsingSaf) {
|
||||
final safBaseUri = _storageService.safBaseUri;
|
||||
if (safBaseUri == null) return;
|
||||
final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show));
|
||||
if (showDir != null) {
|
||||
await _deleteSafDirRecursive(showDir.uri, description: 'show directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final showDir = await _storageService.getShowDirectory(show);
|
||||
if (await showDir.exists()) {
|
||||
await showDir.delete(recursive: true);
|
||||
appLogger.i('Deleted show directory: ${showDir.path}');
|
||||
}
|
||||
Future<void> _deleteShowStorageDirectory(MediaItem show) {
|
||||
return _deleteStorageDirectory(
|
||||
safComponents: () => _storageService.getShowSafPathComponents(show),
|
||||
fileDirectory: () => _storageService.getShowDirectory(show),
|
||||
label: 'show',
|
||||
);
|
||||
}
|
||||
|
||||
/// Safety net: after metadata-based deletion, verify the actual DB-recorded
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:io';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/downloaded_version_match.dart';
|
||||
import 'download_storage_service.dart';
|
||||
|
||||
/// A downloaded copy resolved to a playable location, plus the version that is
|
||||
/// actually on disk — which can differ from the requested one when
|
||||
/// [resolveDownloadedVideoSource] was allowed to fall back.
|
||||
typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId});
|
||||
|
||||
/// Single source of truth for "where is the playable copy of this downloaded
|
||||
/// row, and is it the version that was asked for".
|
||||
///
|
||||
/// Returns null when the row cannot back playback: the download is not
|
||||
/// complete, it holds a different version than requested (unless
|
||||
/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored
|
||||
/// file is gone from disk.
|
||||
///
|
||||
/// Version matching is strict by default so online flows keep streaming an
|
||||
/// explicitly requested non-downloaded version (issue #1440). With
|
||||
/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch
|
||||
/// instead — for offline flows where the alternative is failing outright.
|
||||
///
|
||||
/// Callers own their own preconditions (profile ownership, how the row was
|
||||
/// looked up); this only judges the row itself.
|
||||
Future<DownloadedVideoSource?> resolveDownloadedVideoSource(
|
||||
DownloadedMediaItem row, {
|
||||
int? requestedMediaIndex,
|
||||
String? requestedMediaSourceId,
|
||||
bool allowAnyDownloadedVersion = false,
|
||||
}) async {
|
||||
if (row.status != DownloadStatus.completed.index) {
|
||||
appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!downloadedVersionMatches(
|
||||
row,
|
||||
requestedMediaIndex: requestedMediaIndex,
|
||||
requestedMediaSourceId: requestedMediaSourceId,
|
||||
)) {
|
||||
if (!allowAnyDownloadedVersion) {
|
||||
appLogger.d(
|
||||
'[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} '
|
||||
'(source ${row.mediaSourceId}), but requested version $requestedMediaIndex '
|
||||
'(source ${requestedMediaSourceId?.trim()}) — skipping offline',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
appLogger.d(
|
||||
'[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) '
|
||||
'is not downloaded — falling back to downloaded version ${row.mediaIndex} '
|
||||
'(source ${row.mediaSourceId})',
|
||||
);
|
||||
}
|
||||
|
||||
final storedPath = row.videoFilePath;
|
||||
if (storedPath == null) {
|
||||
appLogger.d('Video file path is null for ${row.globalKey}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final storageService = DownloadStorageService.instance;
|
||||
// SAF URIs (content://) are already playable and come back untouched; file
|
||||
// paths may be stored relative, so resolve them and confirm they still exist.
|
||||
final readablePath = await storageService.getReadablePath(storedPath);
|
||||
if (!storageService.isSafUri(storedPath) && !await File(readablePath).exists()) {
|
||||
appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)');
|
||||
return null;
|
||||
}
|
||||
|
||||
appLogger.d('Found offline video: $readablePath');
|
||||
return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId);
|
||||
}
|
||||
@@ -30,70 +30,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
||||
Future<void> toggleFullscreen() async {
|
||||
if (!PlatformDetector.isDesktopOS()) return;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen();
|
||||
if (isCurrentlyFullscreen) {
|
||||
await MacOSWindowService.exitFullscreen();
|
||||
} else {
|
||||
await MacOSWindowService.enterFullscreen();
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
// Route through the native Win32 runner, which restores to the monitor
|
||||
// the window is currently on (window_manager 0.5.1 picks the wrong one
|
||||
// on multi-monitor setups — see issue #880). The native code also
|
||||
// preserves maximized state internally, so no unmaximize dance here.
|
||||
final isCurrentlyFullscreen = await NativeWindowService.isFullScreen();
|
||||
await NativeWindowService.setFullScreen(!isCurrentlyFullscreen);
|
||||
} else {
|
||||
final isCurrentlyFullscreen = await windowManager.isFullScreen();
|
||||
if (isCurrentlyFullscreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
if (_wasMaximized) {
|
||||
await windowManager.maximize();
|
||||
_wasMaximized = false;
|
||||
}
|
||||
} else {
|
||||
_wasMaximized = await windowManager.isMaximized();
|
||||
if (_wasMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setFullScreen(true);
|
||||
}
|
||||
}
|
||||
final isCurrentlyFullscreen = await _platformIsFullscreen();
|
||||
await _platformSetFullscreen(!isCurrentlyFullscreen);
|
||||
}
|
||||
|
||||
/// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit.
|
||||
Future<void> enterFullscreen() async {
|
||||
if (!PlatformDetector.isDesktopOS()) return;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
await MacOSWindowService.enterFullscreen();
|
||||
} else if (Platform.isWindows) {
|
||||
await NativeWindowService.setFullScreen(true);
|
||||
} else {
|
||||
_wasMaximized = await windowManager.isMaximized();
|
||||
if (_wasMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setFullScreen(true);
|
||||
}
|
||||
await _platformSetFullscreen(true);
|
||||
}
|
||||
|
||||
/// Exit fullscreen, restoring maximized state if needed
|
||||
Future<void> exitFullscreen() async {
|
||||
if (!PlatformDetector.isDesktopOS()) return;
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
await MacOSWindowService.exitFullscreen();
|
||||
} else if (Platform.isWindows) {
|
||||
await NativeWindowService.setFullScreen(false);
|
||||
} else {
|
||||
await windowManager.setFullScreen(false);
|
||||
if (_wasMaximized) {
|
||||
await windowManager.maximize();
|
||||
_wasMaximized = false;
|
||||
}
|
||||
}
|
||||
await _platformSetFullscreen(false);
|
||||
}
|
||||
|
||||
/// Exits fullscreen when the platform window is currently fullscreen.
|
||||
@@ -103,17 +55,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
||||
Future<bool> exitFullscreenIfActive() async {
|
||||
if (!PlatformDetector.isDesktopOS()) return false;
|
||||
|
||||
final isActive = Platform.isMacOS
|
||||
? await MacOSWindowService.isFullscreen()
|
||||
: Platform.isWindows
|
||||
? await NativeWindowService.isFullScreen()
|
||||
: await windowManager.isFullScreen();
|
||||
final isActive = await _platformIsFullscreen();
|
||||
if (!isActive) return false;
|
||||
|
||||
await exitFullscreen();
|
||||
await _platformSetFullscreen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _platformIsFullscreen() {
|
||||
if (Platform.isMacOS) return MacOSWindowService.isFullscreen();
|
||||
if (Platform.isWindows) return NativeWindowService.isFullScreen();
|
||||
return windowManager.isFullScreen();
|
||||
}
|
||||
|
||||
Future<void> _platformSetFullscreen(bool value) async {
|
||||
if (Platform.isMacOS) {
|
||||
if (value) {
|
||||
await MacOSWindowService.enterFullscreen();
|
||||
} else {
|
||||
await MacOSWindowService.exitFullscreen();
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
// Route through the native Win32 runner, which restores to the monitor
|
||||
// the window is currently on (window_manager 0.5.1 picks the wrong one
|
||||
// on multi-monitor setups — see issue #880). The native code also
|
||||
// preserves maximized state internally, so no unmaximize dance here.
|
||||
await NativeWindowService.setFullScreen(value);
|
||||
} else if (value) {
|
||||
_wasMaximized = await windowManager.isMaximized();
|
||||
if (_wasMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setFullScreen(true);
|
||||
} else {
|
||||
await windowManager.setFullScreen(false);
|
||||
if (_wasMaximized) {
|
||||
await windowManager.maximize();
|
||||
_wasMaximized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void startMonitoring() {
|
||||
if (!_shouldMonitor() || _isListening) return;
|
||||
|
||||
|
||||
@@ -76,6 +76,20 @@ part 'jellyfin_client/parts/live_tv.dart';
|
||||
part 'jellyfin_client/parts/images_downloads.dart';
|
||||
part 'jellyfin_client/parts/metadata_edit.dart';
|
||||
|
||||
/// Canonical declarations of the [JellyfinClient] internals that the `part`
|
||||
/// mixins call into.
|
||||
///
|
||||
/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is
|
||||
/// declared exactly once here instead of being re-declared per file. Members
|
||||
/// used by a single part stay declared in that part.
|
||||
mixin _JellyfinClientInternals on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
MediaItem? _mapItem(Map<String, dynamic> json);
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
String? _absolutizeImagePath(String? path);
|
||||
}
|
||||
|
||||
/// [MediaServerClient] over a Jellyfin server.
|
||||
///
|
||||
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
|
||||
@@ -85,6 +99,7 @@ part 'jellyfin_client/parts/metadata_edit.dart';
|
||||
class JellyfinClient
|
||||
with
|
||||
MediaServerCacheMixin,
|
||||
_JellyfinClientInternals,
|
||||
_JellyfinBrowseMethods,
|
||||
_JellyfinMusicMethods,
|
||||
_JellyfinPlaybackMethods,
|
||||
|
||||
@@ -30,6 +30,27 @@ List<Map<String, dynamic>> _itemsArray(Object? data) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Builds a [LibraryPage] from an `/Items`-shaped response: the `Items` array
|
||||
/// run through [map], plus the server's `TotalRecordCount` when it reports one.
|
||||
/// Responses that omit it (or return a non-int) fall back to
|
||||
/// [fallbackPageTotal], whose full-page sentinel keeps pagination enabled;
|
||||
/// [singlePage] endpoints return everything at once, so a full page there means
|
||||
/// the end of the list, not "there may be more".
|
||||
LibraryPage<T> _pagedItems<T>(
|
||||
Object? data, {
|
||||
required int offset,
|
||||
required List<T> Function(List<Map<String, dynamic>>) map,
|
||||
int? requestedSize,
|
||||
bool singlePage = false,
|
||||
}) {
|
||||
final rawItems = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = singlePage
|
||||
? offset + rawItems.length
|
||||
: fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
return LibraryPage<T>(items: map(rawItems), totalCount: rawTotal is int ? rawTotal : fallbackTotal, offset: offset);
|
||||
}
|
||||
|
||||
/// Slim field set for grid/list browsing — what the card UI actually
|
||||
/// renders (title, year, watched badge, episode count for series).
|
||||
///
|
||||
@@ -145,12 +166,7 @@ const _detailFields =
|
||||
// any extra round-trip.
|
||||
'ProviderIds';
|
||||
|
||||
mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
MediaItem? _mapItem(Map<String, dynamic> json);
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
|
||||
// generates (cross-checked against the Findroid client). The SDK mixes
|
||||
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
|
||||
@@ -700,7 +716,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
final items = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) {
|
||||
return _pagedMediaItems(data, offset: offset, requestedSize: pageSize);
|
||||
return _pagedItems(data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
}
|
||||
} on MediaServerHttpException {
|
||||
@@ -722,7 +738,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
|
||||
@@ -754,7 +770,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
/// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query
|
||||
@@ -925,27 +941,19 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
required String includeItemTypes,
|
||||
bool byAlbumArtist = false,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
abort?.throwIfAborted();
|
||||
final page = await _fetchPlayableDescendantsPage(
|
||||
}) {
|
||||
return drainPages<MediaItem>(
|
||||
(start, size) => _fetchPlayableDescendantsPage(
|
||||
parentId,
|
||||
start: start,
|
||||
size: _pagedListPageSize,
|
||||
size: size,
|
||||
abort: abort,
|
||||
includeItemTypes: includeItemTypes,
|
||||
byAlbumArtist: byAlbumArtist,
|
||||
);
|
||||
abort?.throwIfAborted();
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
abort?.throwIfAborted();
|
||||
return all;
|
||||
),
|
||||
pageSize: _pagedListPageSize,
|
||||
abort: abort,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -991,7 +999,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
/// All episodes of a series in the app's **aired watch order** — primarily by
|
||||
@@ -1146,18 +1154,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPersonMedia(String personId) async {
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Future<List<MediaItem>> fetchPersonMedia(String personId) => drainPages<MediaItem>(
|
||||
(start, size) => fetchPersonMediaPage(personId, start: start, size: size),
|
||||
pageSize: _pagedListPageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
||||
@@ -1186,7 +1186,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
||||
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1637,16 +1637,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
try {
|
||||
final response = await _http.get(path, queryParameters: queryParameters, abort: abort);
|
||||
throwIfHttpError(response);
|
||||
final data = response.data;
|
||||
final rawItems = data is List ? data.whereType<Map<String, dynamic>>().toList() : _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = singlePage
|
||||
? offset + rawItems.length
|
||||
: fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(rawItems),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
return _pagedItems(
|
||||
response.data,
|
||||
offset: offset,
|
||||
requestedSize: requestedSize,
|
||||
singlePage: singlePage,
|
||||
map: _mapItems,
|
||||
);
|
||||
} catch (e, st) {
|
||||
appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st);
|
||||
@@ -1654,17 +1650,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
}
|
||||
}
|
||||
|
||||
LibraryPage<MediaItem> _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) {
|
||||
final rawItems = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(rawItems),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
|
||||
final response = await _http.get(
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
|
||||
static const int _collectionsPageSize = 36;
|
||||
|
||||
String? _boxSetsViewId;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
||||
final all = <MediaItem>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize);
|
||||
all.addAll(page.items);
|
||||
if (page.items.isEmpty) break;
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Future<List<MediaItem>> fetchCollections(String libraryId) => drainPages<MediaItem>(
|
||||
(start, size) => fetchCollectionsPage(libraryId, start: start, size: size),
|
||||
pageSize: _collectionsPageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||||
@@ -54,7 +42,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _itemsPage(response.data, offset: s, requestedSize: pageSize);
|
||||
return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
|
||||
@@ -73,14 +61,6 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
return null;
|
||||
}
|
||||
|
||||
LibraryPage<MediaItem> _itemsPage(Object? data, {required int offset, int? requestedSize}) {
|
||||
final rawItems = _itemsArray(data);
|
||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||
final total = rawTotal is int ? rawTotal : fallbackTotal;
|
||||
return LibraryPage<MediaItem>(items: _mapItems(rawItems), totalCount: total, offset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
@@ -104,7 +84,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
return _itemsPage(response.data, offset: s, requestedSize: size);
|
||||
return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinFileInfoMethods on MediaServerCacheMixin {
|
||||
mixin _JellyfinFileInfoMethods on _JellyfinClientInternals {
|
||||
@override
|
||||
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
|
||||
// Lightweight browse responses omit `MediaSources`; detail and some cached
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
|
||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
|
||||
String itemId, {
|
||||
int sourceIndex = 0,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
String? _absolutizeImagePath(String? path);
|
||||
mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(
|
||||
String path,
|
||||
Map<String, dynamic> queryParameters, {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
|
||||
mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals {
|
||||
Future<Map<String, dynamic>?> fetchEditableMetadataItem(String itemId) async {
|
||||
if (isOfflineMode) return null;
|
||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}');
|
||||
|
||||
@@ -4,11 +4,7 @@ part of '../../jellyfin_client.dart';
|
||||
/// listings, instant mix, and lyrics. Endpoint conventions follow the
|
||||
/// Jellyfin web client's music surface (cross-checked against the Kotlin
|
||||
/// SDK), mirroring the style notes at the top of `browse.dart`.
|
||||
mixin _JellyfinMusicMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
mixin _JellyfinMusicMethods on _JellyfinClientInternals {
|
||||
/// Albums credited to [artist], newest first. Queries `AlbumArtistIds`
|
||||
/// rather than `ParentId` because Jellyfin links albums to artists via
|
||||
/// tags — an artist's albums are usually not its folder children.
|
||||
|
||||
@@ -9,36 +9,7 @@ bool _canUseJellyfinStaticStreamFallback(Object error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
PlaybackException _classifyJellyfinPlaybackFailure(Object error) {
|
||||
if (error is MediaServerAuthException ||
|
||||
error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) {
|
||||
return PlaybackException(
|
||||
t.messages.playbackAuthenticationRequired,
|
||||
reason: PlaybackFailureReason.authenticationRequired,
|
||||
);
|
||||
}
|
||||
if (error is MediaServerHttpException) {
|
||||
if (error.isCancellation) {
|
||||
return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled);
|
||||
}
|
||||
final status = error.statusCode;
|
||||
if (error.isTransient || status != null && status >= 500) {
|
||||
return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable);
|
||||
}
|
||||
if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) {
|
||||
return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData);
|
||||
}
|
||||
}
|
||||
if (error is FormatException || error is TypeError) {
|
||||
return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData);
|
||||
}
|
||||
return PlaybackException(t.messages.playbackFailed);
|
||||
}
|
||||
|
||||
mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
|
||||
mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
/// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters
|
||||
/// at the item level (`raw['Chapters']`) and native skip segments through a
|
||||
/// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort
|
||||
@@ -230,7 +201,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
|
||||
} catch (error, stackTrace) {
|
||||
if (!_canUseJellyfinStaticStreamFallback(error)) {
|
||||
Error.throwWithStackTrace(_classifyJellyfinPlaybackFailure(error), stackTrace);
|
||||
Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace);
|
||||
}
|
||||
appLogger.w(
|
||||
'Jellyfin playback negotiation unavailable; using the static stream',
|
||||
@@ -750,20 +721,16 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
@override
|
||||
Map<String, String> get streamHeaders => const {};
|
||||
|
||||
/// Tell the server the user has started playing [itemId]. Body shape
|
||||
/// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the
|
||||
/// same fields, and Jellyfin's session tracker drops events that omit
|
||||
/// `PlayMethod` because it has no way to associate progress with an
|
||||
/// active session row.
|
||||
///
|
||||
/// [duration] is accepted for interface symmetry with Plex but ignored —
|
||||
/// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
|
||||
/// are still sent so the active session reflects the chosen tracks.
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
/// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and
|
||||
/// [isPaused] differ between start and progress. Shape mirrors the Jellyfin
|
||||
/// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same
|
||||
/// fields, and Jellyfin's session tracker drops events that omit `PlayMethod`
|
||||
/// because it has no way to associate progress with an active session row.
|
||||
Future<void> _postPlayingState(
|
||||
String path, {
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
required bool isPaused,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? liveStreamId,
|
||||
@@ -772,44 +739,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
int? subtitleStreamIndex,
|
||||
}) async {
|
||||
final response = await _http.post(
|
||||
'/Sessions/Playing',
|
||||
body: {
|
||||
'ItemId': itemId,
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
'AudioStreamIndex': ?audioStreamIndex,
|
||||
'SubtitleStreamIndex': ?subtitleStreamIndex,
|
||||
'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
|
||||
'CanSeek': true,
|
||||
'IsPaused': false,
|
||||
'IsMuted': false,
|
||||
'PlayMethod': playMethod ?? 'DirectPlay',
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'PlaybackOrder': 'Default',
|
||||
'PlaySessionId': ?playSessionId,
|
||||
'LiveStreamId': ?liveStreamId,
|
||||
},
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
/// Periodic progress ping (5–10s cadence is typical). Server uses this to
|
||||
/// drive the resume position, detect idle sessions, and save remembered
|
||||
/// audio/subtitle stream indexes when enabled in Jellyfin user settings.
|
||||
@override
|
||||
Future<void> reportPlaybackProgress({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
bool isPaused = false,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? liveStreamId,
|
||||
String? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
}) async {
|
||||
final response = await _http.post(
|
||||
'/Sessions/Playing/Progress',
|
||||
path,
|
||||
body: {
|
||||
'ItemId': itemId,
|
||||
'MediaSourceId': ?mediaSourceId,
|
||||
@@ -829,6 +759,63 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
|
||||
throwIfHttpError(response);
|
||||
}
|
||||
|
||||
/// Tell the server the user has started playing [itemId].
|
||||
///
|
||||
/// [duration] is accepted for interface symmetry with Plex but ignored —
|
||||
/// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes
|
||||
/// are still sent so the active session reflects the chosen tracks.
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
Duration? duration,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? liveStreamId,
|
||||
String? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
}) => _postPlayingState(
|
||||
'/Sessions/Playing',
|
||||
itemId: itemId,
|
||||
position: position,
|
||||
isPaused: false,
|
||||
playSessionId: playSessionId,
|
||||
playMethod: playMethod,
|
||||
liveStreamId: liveStreamId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
audioStreamIndex: audioStreamIndex,
|
||||
subtitleStreamIndex: subtitleStreamIndex,
|
||||
);
|
||||
|
||||
/// Periodic progress ping (5–10s cadence is typical). Server uses this to
|
||||
/// drive the resume position, detect idle sessions, and save remembered
|
||||
/// audio/subtitle stream indexes when enabled in Jellyfin user settings.
|
||||
@override
|
||||
Future<void> reportPlaybackProgress({
|
||||
required String itemId,
|
||||
required Duration position,
|
||||
required Duration duration,
|
||||
bool isPaused = false,
|
||||
String? playSessionId,
|
||||
String? playMethod,
|
||||
String? liveStreamId,
|
||||
String? mediaSourceId,
|
||||
int? audioStreamIndex,
|
||||
int? subtitleStreamIndex,
|
||||
}) => _postPlayingState(
|
||||
'/Sessions/Playing/Progress',
|
||||
itemId: itemId,
|
||||
position: position,
|
||||
isPaused: isPaused,
|
||||
playSessionId: playSessionId,
|
||||
playMethod: playMethod,
|
||||
liveStreamId: liveStreamId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
audioStreamIndex: audioStreamIndex,
|
||||
subtitleStreamIndex: subtitleStreamIndex,
|
||||
);
|
||||
|
||||
/// End-of-playback signal. Final position becomes the resume bookmark.
|
||||
/// [duration] is accepted for interface symmetry with Plex but ignored.
|
||||
@override
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
String? _absolutizeImagePath(String? path);
|
||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||
|
||||
mixin _JellyfinPlaylistMethods on _JellyfinClientInternals {
|
||||
static const int _playlistsPageSize = 200;
|
||||
|
||||
@override
|
||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
|
||||
final all = <MediaPlaylist>[];
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final page = await fetchPlaylistsPage(
|
||||
playlistType: playlistType,
|
||||
smart: smart,
|
||||
start: start,
|
||||
size: _playlistsPageSize,
|
||||
);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
start += page.items.length;
|
||||
if (start >= page.totalCount) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) => drainPages<MediaPlaylist>(
|
||||
(start, size) => fetchPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size),
|
||||
pageSize: _playlistsPageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
||||
@@ -70,15 +52,11 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final items = _itemsArray(response.data).map(_playlistFromJson).toList();
|
||||
final rawTotal = response.data is Map<String, dynamic>
|
||||
? (response.data as Map<String, dynamic>)['TotalRecordCount']
|
||||
: null;
|
||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize);
|
||||
return LibraryPage<MediaPlaylist>(
|
||||
items: items,
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
return _pagedItems(
|
||||
response.data,
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
map: (raw) => raw.map(_playlistFromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,16 +103,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin {
|
||||
abort: abort,
|
||||
);
|
||||
throwIfHttpError(response);
|
||||
final items = _itemsArray(response.data);
|
||||
final rawTotal = response.data is Map<String, dynamic>
|
||||
? (response.data as Map<String, dynamic>)['TotalRecordCount']
|
||||
: null;
|
||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: _mapItems(items),
|
||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
||||
offset: offset,
|
||||
);
|
||||
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
part of '../../jellyfin_client.dart';
|
||||
|
||||
mixin _JellyfinWatchStateMethods on MediaServerCacheMixin {
|
||||
JellyfinConnection get connection;
|
||||
FailoverHttpClient get _http;
|
||||
|
||||
mixin _JellyfinWatchStateMethods on _JellyfinClientInternals {
|
||||
@override
|
||||
Future<void> markWatched(MediaItem item) async {
|
||||
final response = await _http.post(
|
||||
|
||||
@@ -455,6 +455,16 @@ class JellyfinEndpointDiscovery {
|
||||
return List.unmodifiable(result);
|
||||
}
|
||||
|
||||
/// Splits a raw add/edit form field into the individual URLs the user typed.
|
||||
/// Entries are separated by newlines and/or commas; blanks are dropped.
|
||||
static List<String> parseUserEnteredUrls(String raw) {
|
||||
return raw
|
||||
.split(RegExp(r'[\n,]+'))
|
||||
.map((url) => url.trim())
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable<String> input) {
|
||||
final probeBaseUrls = <String>[];
|
||||
final explicitBaseUrls = <String>[];
|
||||
|
||||
@@ -137,6 +137,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher {
|
||||
/// Launch playback from a Jellyfin folder row. Jellyfin has no server-side
|
||||
/// queue resource, so folders use the same local queue path as collections.
|
||||
/// The client query is video-only; music-only folders return [PlayQueueEmpty].
|
||||
@override
|
||||
Future<PlayQueueResult> launchFromFolder({
|
||||
required MediaItem folder,
|
||||
required bool shuffle,
|
||||
|
||||
@@ -8,12 +8,11 @@ import '../i18n/strings.g.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'settings_binding_owner.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'shortcut_action.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
|
||||
class KeyboardShortcutsService extends ChangeNotifier {
|
||||
static const Set<String> _repeatableVideoActions = {'zoom_in', 'zoom_out'};
|
||||
|
||||
static KeyboardShortcutsService? _instance;
|
||||
static Future<void>? _initialization;
|
||||
late final SettingsBindingOwner _settingsBinding;
|
||||
@@ -218,12 +217,15 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
final isMetaPressed = HardwareKeyboard.instance.isMetaPressed;
|
||||
|
||||
for (final entry in _hotkeys.entries) {
|
||||
final action = entry.key;
|
||||
final hotkey = entry.value;
|
||||
if (hotkey == null) continue;
|
||||
|
||||
if (physicalKey != hotkey.key) continue;
|
||||
|
||||
// Null for an id this build does not know: the event is still consumed so
|
||||
// a stale binding never leaks through to another handler.
|
||||
final action = ShortcutAction.fromId(entry.key);
|
||||
|
||||
final requiredModifiers = hotkey.modifiers ?? [];
|
||||
bool modifiersMatch = true;
|
||||
|
||||
@@ -265,30 +267,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRepeat && !_repeatableVideoActions.contains(action)) {
|
||||
if (isRepeat && !(action?.repeatable ?? false)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
const playbackControlledActions = <String>{
|
||||
'play_pause',
|
||||
'seek_forward',
|
||||
'seek_backward',
|
||||
'seek_forward_large',
|
||||
'seek_backward_large',
|
||||
'audio_track_next',
|
||||
'subtitle_track_next',
|
||||
'chapter_next',
|
||||
'chapter_previous',
|
||||
'speed_increase',
|
||||
'speed_decrease',
|
||||
'speed_reset',
|
||||
'sub_seek_next',
|
||||
'sub_seek_prev',
|
||||
'skip_marker',
|
||||
};
|
||||
const mediaItemActions = <String>{'episode_next', 'episode_previous'};
|
||||
if ((playbackControlledActions.contains(action) && !canControlPlayback) ||
|
||||
(mediaItemActions.contains(action) && !canNavigateMediaItems)) {
|
||||
if (action == null ||
|
||||
(action.requiresPlayback && !canControlPlayback) ||
|
||||
(action.requiresMediaNavigation && !canNavigateMediaItems)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@@ -326,7 +311,7 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _executeAction(
|
||||
String action,
|
||||
ShortcutAction action,
|
||||
Player player,
|
||||
VoidCallback? onToggleFullscreen,
|
||||
VoidCallback? onToggleSubtitles,
|
||||
@@ -363,154 +348,72 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'play_pause':
|
||||
case ShortcutAction.playPause:
|
||||
(onPlayPause ?? player.playOrPause).call();
|
||||
break;
|
||||
case 'volume_up':
|
||||
case ShortcutAction.volumeUp:
|
||||
onVolumeUp?.call();
|
||||
break;
|
||||
case 'volume_down':
|
||||
case ShortcutAction.volumeDown:
|
||||
onVolumeDown?.call();
|
||||
break;
|
||||
case 'seek_forward':
|
||||
case ShortcutAction.seekForward:
|
||||
performSeek(_seekTimeSmall);
|
||||
break;
|
||||
case 'seek_backward':
|
||||
case ShortcutAction.seekBackward:
|
||||
performSeek(-_seekTimeSmall);
|
||||
break;
|
||||
case 'seek_forward_large':
|
||||
case ShortcutAction.seekForwardLarge:
|
||||
performSeek(_seekTimeLarge);
|
||||
break;
|
||||
case 'seek_backward_large':
|
||||
case ShortcutAction.seekBackwardLarge:
|
||||
performSeek(-_seekTimeLarge);
|
||||
break;
|
||||
case 'fullscreen_toggle':
|
||||
case ShortcutAction.fullscreenToggle:
|
||||
onToggleFullscreen?.call();
|
||||
break;
|
||||
case 'mute_toggle':
|
||||
case ShortcutAction.muteToggle:
|
||||
onToggleMute?.call();
|
||||
break;
|
||||
case 'subtitle_toggle':
|
||||
case ShortcutAction.subtitleToggle:
|
||||
onToggleSubtitles?.call();
|
||||
break;
|
||||
case 'audio_track_next':
|
||||
case ShortcutAction.audioTrackNext:
|
||||
onNextAudioTrack?.call();
|
||||
break;
|
||||
case 'subtitle_track_next':
|
||||
case ShortcutAction.subtitleTrackNext:
|
||||
onNextSubtitleTrack?.call();
|
||||
break;
|
||||
case 'chapter_next':
|
||||
case ShortcutAction.chapterNext:
|
||||
onNextChapter?.call();
|
||||
break;
|
||||
case 'chapter_previous':
|
||||
case ShortcutAction.chapterPrevious:
|
||||
onPreviousChapter?.call();
|
||||
break;
|
||||
case 'episode_next':
|
||||
case ShortcutAction.episodeNext:
|
||||
onNextEpisode?.call();
|
||||
break;
|
||||
case 'episode_previous':
|
||||
case ShortcutAction.episodePrevious:
|
||||
onPreviousEpisode?.call();
|
||||
break;
|
||||
case 'speed_increase':
|
||||
case ShortcutAction.speedIncrease:
|
||||
final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0);
|
||||
player.setRate(newRateUp);
|
||||
_settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp);
|
||||
break;
|
||||
case 'speed_decrease':
|
||||
case ShortcutAction.speedDecrease:
|
||||
final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0);
|
||||
player.setRate(newRateDown);
|
||||
_settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown);
|
||||
break;
|
||||
case 'speed_reset':
|
||||
case ShortcutAction.speedReset:
|
||||
player.setRate(1.0);
|
||||
_settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0);
|
||||
break;
|
||||
case 'sub_seek_next':
|
||||
case ShortcutAction.subSeekNext:
|
||||
player.command(['sub-seek', '1']);
|
||||
break;
|
||||
case 'sub_seek_prev':
|
||||
case ShortcutAction.subSeekPrev:
|
||||
player.command(['sub-seek', '-1']);
|
||||
break;
|
||||
case 'shader_toggle':
|
||||
case ShortcutAction.shaderToggle:
|
||||
onToggleShader?.call();
|
||||
break;
|
||||
case 'skip_marker':
|
||||
case ShortcutAction.skipMarker:
|
||||
onSkipMarker?.call();
|
||||
break;
|
||||
case 'screenshot':
|
||||
case ShortcutAction.screenshot:
|
||||
unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call()));
|
||||
break;
|
||||
case 'zoom_in':
|
||||
case ShortcutAction.zoomIn:
|
||||
onZoomIn?.call();
|
||||
break;
|
||||
case 'zoom_out':
|
||||
case ShortcutAction.zoomOut:
|
||||
onZoomOut?.call();
|
||||
break;
|
||||
case 'zoom_reset':
|
||||
case ShortcutAction.zoomReset:
|
||||
onZoomReset?.call();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
String getActionDisplayName(String action) {
|
||||
switch (action) {
|
||||
case 'play_pause':
|
||||
return t.hotkeys.actions.playPause;
|
||||
case 'volume_up':
|
||||
return t.hotkeys.actions.volumeUp;
|
||||
case 'volume_down':
|
||||
return t.hotkeys.actions.volumeDown;
|
||||
case 'seek_forward':
|
||||
return t.hotkeys.actions.seekForward(seconds: _seekTimeSmall);
|
||||
case 'seek_backward':
|
||||
return t.hotkeys.actions.seekBackward(seconds: _seekTimeSmall);
|
||||
case 'seek_forward_large':
|
||||
return t.hotkeys.actions.seekForward(seconds: _seekTimeLarge);
|
||||
case 'seek_backward_large':
|
||||
return t.hotkeys.actions.seekBackward(seconds: _seekTimeLarge);
|
||||
case 'fullscreen_toggle':
|
||||
return t.hotkeys.actions.fullscreenToggle;
|
||||
case 'mute_toggle':
|
||||
return t.hotkeys.actions.muteToggle;
|
||||
case 'subtitle_toggle':
|
||||
return t.hotkeys.actions.subtitleToggle;
|
||||
case 'audio_track_next':
|
||||
return t.hotkeys.actions.audioTrackNext;
|
||||
case 'subtitle_track_next':
|
||||
return t.hotkeys.actions.subtitleTrackNext;
|
||||
case 'chapter_next':
|
||||
return t.hotkeys.actions.chapterNext;
|
||||
case 'chapter_previous':
|
||||
return t.hotkeys.actions.chapterPrevious;
|
||||
case 'episode_next':
|
||||
return t.hotkeys.actions.episodeNext;
|
||||
case 'episode_previous':
|
||||
return t.hotkeys.actions.episodePrevious;
|
||||
case 'speed_increase':
|
||||
return t.hotkeys.actions.speedIncrease;
|
||||
case 'speed_decrease':
|
||||
return t.hotkeys.actions.speedDecrease;
|
||||
case 'speed_reset':
|
||||
return t.hotkeys.actions.speedReset;
|
||||
case 'sub_seek_next':
|
||||
return t.hotkeys.actions.subSeekNext;
|
||||
case 'sub_seek_prev':
|
||||
return t.hotkeys.actions.subSeekPrev;
|
||||
case 'shader_toggle':
|
||||
return t.hotkeys.actions.shaderToggle;
|
||||
case 'skip_marker':
|
||||
return t.hotkeys.actions.skipMarker;
|
||||
case 'screenshot':
|
||||
return t.hotkeys.actions.screenshot;
|
||||
case 'zoom_in':
|
||||
return t.hotkeys.actions.zoomIn;
|
||||
case 'zoom_out':
|
||||
return t.hotkeys.actions.zoomOut;
|
||||
case 'zoom_reset':
|
||||
return t.hotkeys.actions.zoomReset;
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
final shortcut = ShortcutAction.fromId(action);
|
||||
if (shortcut == null) return action;
|
||||
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
|
||||
}
|
||||
|
||||
// Check if a hotkey is already assigned to another action
|
||||
|
||||
@@ -2,27 +2,6 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'fullscreen_state_manager.dart';
|
||||
|
||||
/// Abstract class for receiving macOS window delegate callbacks.
|
||||
/// Extend this class and register with [MacOSWindowService] to receive
|
||||
/// fullscreen transition events.
|
||||
abstract class MacOSWindowDelegate {
|
||||
/// Called when the window is about to enter fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowWillEnterFullScreen() {}
|
||||
|
||||
/// Called when the window has entered fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowDidEnterFullScreen() {}
|
||||
|
||||
/// Called when the window is about to exit fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowWillExitFullScreen() {}
|
||||
|
||||
/// Called when the window has exited fullscreen mode.
|
||||
// ignore: no-empty-block - default no-op, subclasses override as needed
|
||||
void windowDidExitFullScreen() {}
|
||||
}
|
||||
|
||||
/// Service for manipulating macOS window properties.
|
||||
/// This is a native implementation replacing the macos_window_utils package.
|
||||
///
|
||||
@@ -31,35 +10,25 @@ abstract class MacOSWindowDelegate {
|
||||
/// This service only exposes what's needed externally:
|
||||
/// - Traffic light visibility (for video controls)
|
||||
/// - Fullscreen enter/exit (for video controls)
|
||||
/// - Delegate registration (for FullscreenStateManager updates)
|
||||
/// - Fullscreen state tracking (for FullscreenStateManager updates)
|
||||
class MacOSWindowService {
|
||||
static const _channel = MethodChannel('com.plezy/window_utils');
|
||||
static bool _initialized = false;
|
||||
static bool _delegateEnabled = false;
|
||||
static final List<MacOSWindowDelegate> _delegates = [];
|
||||
static final MacOSWindowDelegate _fullscreenDelegate = _FullscreenWindowDelegate();
|
||||
|
||||
static Future<void> _invoke(String method, [Map<String, dynamic>? args]) async {
|
||||
if (!Platform.isMacOS) return;
|
||||
await _channel.invokeMethod(method, args);
|
||||
}
|
||||
|
||||
static void _notifyDelegates(void Function(MacOSWindowDelegate) callback) {
|
||||
for (final delegate in _delegates) {
|
||||
callback(delegate);
|
||||
}
|
||||
}
|
||||
|
||||
/// Window manipulation (toolbar, titlebar, traffic lights) is handled directly
|
||||
/// in Swift's WindowDelegate; this only mirrors the transition into Dart state.
|
||||
static Future<dynamic> _handleMethodCall(MethodCall call) async {
|
||||
switch (call.method) {
|
||||
case 'windowWillEnterFullScreen':
|
||||
_notifyDelegates((d) => d.windowWillEnterFullScreen());
|
||||
case 'windowDidEnterFullScreen':
|
||||
_notifyDelegates((d) => d.windowDidEnterFullScreen());
|
||||
case 'windowWillExitFullScreen':
|
||||
_notifyDelegates((d) => d.windowWillExitFullScreen());
|
||||
FullscreenStateManager().setFullscreen(true);
|
||||
case 'windowDidExitFullScreen':
|
||||
_notifyDelegates((d) => d.windowDidExitFullScreen());
|
||||
FullscreenStateManager().setFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +50,6 @@ class MacOSWindowService {
|
||||
}
|
||||
|
||||
await initialize(enableWindowDelegate: true);
|
||||
addWindowDelegate(_fullscreenDelegate);
|
||||
await syncWindowChrome();
|
||||
FullscreenStateManager().setFullscreen(await isFullscreen());
|
||||
}
|
||||
@@ -104,12 +72,6 @@ class MacOSWindowService {
|
||||
}
|
||||
}
|
||||
|
||||
static void addWindowDelegate(MacOSWindowDelegate delegate) {
|
||||
if (!_delegates.contains(delegate)) {
|
||||
_delegates.add(delegate);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible});
|
||||
|
||||
static Future<void> syncWindowChrome() => _invoke('syncWindowChrome');
|
||||
@@ -123,18 +85,3 @@ class MacOSWindowService {
|
||||
return await _channel.invokeMethod<bool>('isFullscreen') ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal window delegate that manages fullscreen state.
|
||||
/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled
|
||||
/// directly in Swift's WindowDelegate. This class only updates Dart-side state.
|
||||
class _FullscreenWindowDelegate extends MacOSWindowDelegate {
|
||||
@override
|
||||
void windowWillEnterFullScreen() {
|
||||
FullscreenStateManager().setFullscreen(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void windowDidExitFullScreen() {
|
||||
FullscreenStateManager().setFullscreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -1,12 +1,14 @@
|
||||
import 'package:os_media_controls/os_media_controls.dart';
|
||||
|
||||
/// Screen-owned authorization boundary for user-originated OS media commands.
|
||||
/// Authorization boundary for user-originated OS media commands, owned by
|
||||
/// whoever holds the transport (the video screen, the music session).
|
||||
///
|
||||
/// Lifecycle/audio-route events are handled before this router. Recognized
|
||||
/// commands are consumed even when denied so they cannot reach a background
|
||||
/// route or stale player owner.
|
||||
final class VideoPlayerMediaControlRouter {
|
||||
const VideoPlayerMediaControlRouter({
|
||||
/// Lifecycle/audio-route events are handled before this router: [route]
|
||||
/// reports `false` for what it does not recognize. Recognized commands are
|
||||
/// consumed even when denied so they cannot reach a background route or stale
|
||||
/// player owner. Both gates stay required — every owner states its policy.
|
||||
final class MediaControlRouter {
|
||||
const MediaControlRouter({
|
||||
required this.canControlPlayback,
|
||||
required this.canNavigateMediaItems,
|
||||
required this.onPlay,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user