feat: download version picker with smart series matching

close #767
This commit is contained in:
edde746
2026-03-31 04:46:09 +02:00
parent 8eb716505d
commit 1a96d2b418
41 changed files with 442 additions and 85 deletions
+9 -1
View File
@@ -17,7 +17,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 8; // Added bgTaskId column to DownloadedMedia
int get schemaVersion => 9; // Added mediaIndex column to DownloadedMedia
@override
MigrationStrategy get migration {
@@ -38,6 +38,14 @@ class AppDatabase extends _$AppDatabase {
appLogger.w('bgTaskId column may already exist: $e');
}
}
if (from < 9) {
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
try {
await m.addColumn(downloadedMedia, downloadedMedia.mediaIndex);
} catch (e) {
appLogger.w('mediaIndex column may already exist: $e');
}
}
},
);
}
+72 -3
View File
@@ -198,6 +198,18 @@ class $DownloadedMediaTable extends DownloadedMedia
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _mediaIndexMeta = const VerificationMeta(
'mediaIndex',
);
@override
late final GeneratedColumn<int> mediaIndex = GeneratedColumn<int>(
'media_index',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -217,6 +229,7 @@ class $DownloadedMediaTable extends DownloadedMedia
errorMessage,
retryCount,
bgTaskId,
mediaIndex,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -357,6 +370,12 @@ class $DownloadedMediaTable extends DownloadedMedia
bgTaskId.isAcceptableOrUnknown(data['bg_task_id']!, _bgTaskIdMeta),
);
}
if (data.containsKey('media_index')) {
context.handle(
_mediaIndexMeta,
mediaIndex.isAcceptableOrUnknown(data['media_index']!, _mediaIndexMeta),
);
}
return context;
}
@@ -434,6 +453,10 @@ class $DownloadedMediaTable extends DownloadedMedia
DriftSqlType.string,
data['${effectivePrefix}bg_task_id'],
),
mediaIndex: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}media_index'],
)!,
);
}
@@ -462,6 +485,7 @@ class DownloadedMediaItem extends DataClass
final String? errorMessage;
final int retryCount;
final String? bgTaskId;
final int mediaIndex;
const DownloadedMediaItem({
required this.id,
required this.serverId,
@@ -480,6 +504,7 @@ class DownloadedMediaItem extends DataClass
this.errorMessage,
required this.retryCount,
this.bgTaskId,
required this.mediaIndex,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -517,6 +542,7 @@ class DownloadedMediaItem extends DataClass
if (!nullToAbsent || bgTaskId != null) {
map['bg_task_id'] = Variable<String>(bgTaskId);
}
map['media_index'] = Variable<int>(mediaIndex);
return map;
}
@@ -555,6 +581,7 @@ class DownloadedMediaItem extends DataClass
bgTaskId: bgTaskId == null && nullToAbsent
? const Value.absent()
: Value(bgTaskId),
mediaIndex: Value(mediaIndex),
);
}
@@ -583,6 +610,7 @@ class DownloadedMediaItem extends DataClass
errorMessage: serializer.fromJson<String?>(json['errorMessage']),
retryCount: serializer.fromJson<int>(json['retryCount']),
bgTaskId: serializer.fromJson<String?>(json['bgTaskId']),
mediaIndex: serializer.fromJson<int>(json['mediaIndex']),
);
}
@override
@@ -606,6 +634,7 @@ class DownloadedMediaItem extends DataClass
'errorMessage': serializer.toJson<String?>(errorMessage),
'retryCount': serializer.toJson<int>(retryCount),
'bgTaskId': serializer.toJson<String?>(bgTaskId),
'mediaIndex': serializer.toJson<int>(mediaIndex),
};
}
@@ -627,6 +656,7 @@ class DownloadedMediaItem extends DataClass
Value<String?> errorMessage = const Value.absent(),
int? retryCount,
Value<String?> bgTaskId = const Value.absent(),
int? mediaIndex,
}) => DownloadedMediaItem(
id: id ?? this.id,
serverId: serverId ?? this.serverId,
@@ -651,6 +681,7 @@ class DownloadedMediaItem extends DataClass
errorMessage: errorMessage.present ? errorMessage.value : this.errorMessage,
retryCount: retryCount ?? this.retryCount,
bgTaskId: bgTaskId.present ? bgTaskId.value : this.bgTaskId,
mediaIndex: mediaIndex ?? this.mediaIndex,
);
DownloadedMediaItem copyWithCompanion(DownloadedMediaCompanion data) {
return DownloadedMediaItem(
@@ -687,6 +718,9 @@ class DownloadedMediaItem extends DataClass
? data.retryCount.value
: this.retryCount,
bgTaskId: data.bgTaskId.present ? data.bgTaskId.value : this.bgTaskId,
mediaIndex: data.mediaIndex.present
? data.mediaIndex.value
: this.mediaIndex,
);
}
@@ -709,7 +743,8 @@ class DownloadedMediaItem extends DataClass
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
..write('retryCount: $retryCount, ')
..write('bgTaskId: $bgTaskId')
..write('bgTaskId: $bgTaskId, ')
..write('mediaIndex: $mediaIndex')
..write(')'))
.toString();
}
@@ -733,6 +768,7 @@ class DownloadedMediaItem extends DataClass
errorMessage,
retryCount,
bgTaskId,
mediaIndex,
);
@override
bool operator ==(Object other) =>
@@ -754,7 +790,8 @@ class DownloadedMediaItem extends DataClass
other.downloadedAt == this.downloadedAt &&
other.errorMessage == this.errorMessage &&
other.retryCount == this.retryCount &&
other.bgTaskId == this.bgTaskId);
other.bgTaskId == this.bgTaskId &&
other.mediaIndex == this.mediaIndex);
}
class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
@@ -775,6 +812,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
final Value<String?> errorMessage;
final Value<int> retryCount;
final Value<String?> bgTaskId;
final Value<int> mediaIndex;
const DownloadedMediaCompanion({
this.id = const Value.absent(),
this.serverId = const Value.absent(),
@@ -793,6 +831,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.errorMessage = const Value.absent(),
this.retryCount = const Value.absent(),
this.bgTaskId = const Value.absent(),
this.mediaIndex = const Value.absent(),
});
DownloadedMediaCompanion.insert({
this.id = const Value.absent(),
@@ -812,6 +851,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
this.errorMessage = const Value.absent(),
this.retryCount = const Value.absent(),
this.bgTaskId = const Value.absent(),
this.mediaIndex = const Value.absent(),
}) : serverId = Value(serverId),
ratingKey = Value(ratingKey),
globalKey = Value(globalKey),
@@ -835,6 +875,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Expression<String>? errorMessage,
Expression<int>? retryCount,
Expression<String>? bgTaskId,
Expression<int>? mediaIndex,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@@ -855,6 +896,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (errorMessage != null) 'error_message': errorMessage,
if (retryCount != null) 'retry_count': retryCount,
if (bgTaskId != null) 'bg_task_id': bgTaskId,
if (mediaIndex != null) 'media_index': mediaIndex,
});
}
@@ -876,6 +918,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
Value<String?>? errorMessage,
Value<int>? retryCount,
Value<String?>? bgTaskId,
Value<int>? mediaIndex,
}) {
return DownloadedMediaCompanion(
id: id ?? this.id,
@@ -895,6 +938,7 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
errorMessage: errorMessage ?? this.errorMessage,
retryCount: retryCount ?? this.retryCount,
bgTaskId: bgTaskId ?? this.bgTaskId,
mediaIndex: mediaIndex ?? this.mediaIndex,
);
}
@@ -954,6 +998,9 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
if (bgTaskId.present) {
map['bg_task_id'] = Variable<String>(bgTaskId.value);
}
if (mediaIndex.present) {
map['media_index'] = Variable<int>(mediaIndex.value);
}
return map;
}
@@ -976,7 +1023,8 @@ class DownloadedMediaCompanion extends UpdateCompanion<DownloadedMediaItem> {
..write('downloadedAt: $downloadedAt, ')
..write('errorMessage: $errorMessage, ')
..write('retryCount: $retryCount, ')
..write('bgTaskId: $bgTaskId')
..write('bgTaskId: $bgTaskId, ')
..write('mediaIndex: $mediaIndex')
..write(')'))
.toString();
}
@@ -2504,6 +2552,7 @@ typedef $$DownloadedMediaTableCreateCompanionBuilder =
Value<String?> errorMessage,
Value<int> retryCount,
Value<String?> bgTaskId,
Value<int> mediaIndex,
});
typedef $$DownloadedMediaTableUpdateCompanionBuilder =
DownloadedMediaCompanion Function({
@@ -2524,6 +2573,7 @@ typedef $$DownloadedMediaTableUpdateCompanionBuilder =
Value<String?> errorMessage,
Value<int> retryCount,
Value<String?> bgTaskId,
Value<int> mediaIndex,
});
class $$DownloadedMediaTableFilterComposer
@@ -2619,6 +2669,11 @@ class $$DownloadedMediaTableFilterComposer
column: $table.bgTaskId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get mediaIndex => $composableBuilder(
column: $table.mediaIndex,
builder: (column) => ColumnFilters(column),
);
}
class $$DownloadedMediaTableOrderingComposer
@@ -2714,6 +2769,11 @@ class $$DownloadedMediaTableOrderingComposer
column: $table.bgTaskId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get mediaIndex => $composableBuilder(
column: $table.mediaIndex,
builder: (column) => ColumnOrderings(column),
);
}
class $$DownloadedMediaTableAnnotationComposer
@@ -2791,6 +2851,11 @@ class $$DownloadedMediaTableAnnotationComposer
GeneratedColumn<String> get bgTaskId =>
$composableBuilder(column: $table.bgTaskId, builder: (column) => column);
GeneratedColumn<int> get mediaIndex => $composableBuilder(
column: $table.mediaIndex,
builder: (column) => column,
);
}
class $$DownloadedMediaTableTableManager
@@ -2847,6 +2912,7 @@ class $$DownloadedMediaTableTableManager
Value<String?> errorMessage = const Value.absent(),
Value<int> retryCount = const Value.absent(),
Value<String?> bgTaskId = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
}) => DownloadedMediaCompanion(
id: id,
serverId: serverId,
@@ -2865,6 +2931,7 @@ class $$DownloadedMediaTableTableManager
errorMessage: errorMessage,
retryCount: retryCount,
bgTaskId: bgTaskId,
mediaIndex: mediaIndex,
),
createCompanionCallback:
({
@@ -2885,6 +2952,7 @@ class $$DownloadedMediaTableTableManager
Value<String?> errorMessage = const Value.absent(),
Value<int> retryCount = const Value.absent(),
Value<String?> bgTaskId = const Value.absent(),
Value<int> mediaIndex = const Value.absent(),
}) => DownloadedMediaCompanion.insert(
id: id,
serverId: serverId,
@@ -2903,6 +2971,7 @@ class $$DownloadedMediaTableTableManager
errorMessage: errorMessage,
retryCount: retryCount,
bgTaskId: bgTaskId,
mediaIndex: mediaIndex,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
+2
View File
@@ -14,6 +14,7 @@ extension DownloadDatabaseOperations on AppDatabase {
String? parentRatingKey,
String? grandparentRatingKey,
required int status,
int mediaIndex = 0,
}) async {
await into(downloadedMedia).insert(
DownloadedMediaCompanion.insert(
@@ -24,6 +25,7 @@ extension DownloadDatabaseOperations on AppDatabase {
parentRatingKey: Value(parentRatingKey),
grandparentRatingKey: Value(grandparentRatingKey),
status: status,
mediaIndex: Value(mediaIndex),
),
mode: InsertMode.insertOrReplace,
);
+1
View File
@@ -48,6 +48,7 @@ class DownloadedMedia extends Table {
TextColumn get errorMessage => text().nullable()();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
TextColumn get bgTaskId => text().nullable()();
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
}
/// Queue for offline watch progress and manual watch actions.
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Ingen downloads",
"pauseAll": "Pause alle",
"resumeAll": "Genoptag alle",
"deleteAll": "Slet alle"
"deleteAll": "Slet alle",
"selectVersion": "Vælg version"
},
"shaders": {
"title": "Shadere",
+2 -1
View File
@@ -601,7 +601,8 @@
"noDownloadsTree": "Keine Downloads",
"pauseAll": "Alle pausieren",
"resumeAll": "Alle fortsetzen",
"deleteAll": "Alle löschen"
"deleteAll": "Alle löschen",
"selectVersion": "Version auswählen"
},
"playlists": {
"title": "Wiedergabelisten",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "No downloads",
"pauseAll": "Pause all",
"resumeAll": "Resume all",
"deleteAll": "Delete all"
"deleteAll": "Delete all",
"selectVersion": "Select Version"
},
"shaders": {
"title": "Shaders",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Sin descargas",
"pauseAll": "Pausar todo",
"resumeAll": "Reanudar todo",
"deleteAll": "Eliminar todo"
"deleteAll": "Eliminar todo",
"selectVersion": "Seleccionar versión"
},
"shaders": {
"title": "Shaders",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Aucun téléchargement",
"pauseAll": "Tout mettre en pause",
"resumeAll": "Tout reprendre",
"deleteAll": "Tout supprimer"
"deleteAll": "Tout supprimer",
"selectVersion": "Sélectionner la version"
},
"shaders": {
"title": "Shaders",
+2 -1
View File
@@ -601,7 +601,8 @@
"noDownloadsTree": "Nessun download",
"pauseAll": "Metti tutto in pausa",
"resumeAll": "Riprendi tutto",
"deleteAll": "Elimina tutto"
"deleteAll": "Elimina tutto",
"selectVersion": "Seleziona versione"
},
"playlists": {
"title": "Playlist",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "ダウンロードなし",
"pauseAll": "すべて一時停止",
"resumeAll": "すべて再開",
"deleteAll": "すべて削除"
"deleteAll": "すべて削除",
"selectVersion": "バージョンを選択"
},
"shaders": {
"title": "シェーダー",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "다운로드 없음",
"pauseAll": "모두 일시정지",
"resumeAll": "모두 재개",
"deleteAll": "모두 삭제"
"deleteAll": "모두 삭제",
"selectVersion": "버전 선택"
},
"shaders": {
"title": "셰이더",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Ingen nedlastinger",
"pauseAll": "Pause alle",
"resumeAll": "Gjenoppta alle",
"deleteAll": "Slett alle"
"deleteAll": "Slett alle",
"selectVersion": "Velg versjon"
},
"shaders": {
"title": "Shadere",
+2 -1
View File
@@ -601,7 +601,8 @@
"noDownloadsTree": "Geen downloads",
"pauseAll": "Alles pauzeren",
"resumeAll": "Alles hervatten",
"deleteAll": "Alles verwijderen"
"deleteAll": "Alles verwijderen",
"selectVersion": "Versie selecteren"
},
"playlists": {
"title": "Afspeellijsten",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Brak pobrań",
"pauseAll": "Wstrzymaj wszystko",
"resumeAll": "Wznów wszystko",
"deleteAll": "Usuń wszystko"
"deleteAll": "Usuń wszystko",
"selectVersion": "Wybierz wersję"
},
"shaders": {
"title": "Shadery",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Nenhum download",
"pauseAll": "Pausar todos",
"resumeAll": "Retomar todos",
"deleteAll": "Excluir todos"
"deleteAll": "Excluir todos",
"selectVersion": "Selecionar versão"
},
"shaders": {
"title": "Shaders",
+2 -1
View File
@@ -703,7 +703,8 @@
"noDownloadsTree": "Нет загрузок",
"pauseAll": "Приостановить все",
"resumeAll": "Возобновить все",
"deleteAll": "Удалить все"
"deleteAll": "Удалить все",
"selectVersion": "Выбрать версию"
},
"shaders": {
"title": "Шейдеры",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 15
/// Strings: 12120 (808 per locale)
/// Strings: 12135 (809 per locale)
///
/// Built on 2026-03-29 at 18:23 UTC
/// Built on 2026-03-31 at 02:43 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsDa implements TranslationsDownloadsEn {
@override String get pauseAll => 'Pause alle';
@override String get resumeAll => 'Genoptag alle';
@override String get deleteAll => 'Slet alle';
@override String get selectVersion => 'Vælg version';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsDa {
'downloads.pauseAll' => 'Pause alle',
'downloads.resumeAll' => 'Genoptag alle',
'downloads.deleteAll' => 'Slet alle',
'downloads.selectVersion' => 'Vælg version',
'shaders.title' => 'Shadere',
'shaders.noShaderDescription' => 'Ingen videoforbedring',
'shaders.nvscalerDescription' => 'NVIDIA-billedskalering for skarpere video',
+2
View File
@@ -841,6 +841,7 @@ class _TranslationsDownloadsDe implements TranslationsDownloadsEn {
@override String get pauseAll => 'Alle pausieren';
@override String get resumeAll => 'Alle fortsetzen';
@override String get deleteAll => 'Alle löschen';
@override String get selectVersion => 'Version auswählen';
}
// Path: playlists
@@ -1845,6 +1846,7 @@ extension on TranslationsDe {
'downloads.pauseAll' => 'Alle pausieren',
'downloads.resumeAll' => 'Alle fortsetzen',
'downloads.deleteAll' => 'Alle löschen',
'downloads.selectVersion' => 'Version auswählen',
'playlists.title' => 'Wiedergabelisten',
'playlists.noPlaylists' => 'Keine Wiedergabelisten gefunden',
'playlists.create' => 'Wiedergabeliste erstellen',
+4
View File
@@ -2164,6 +2164,9 @@ class TranslationsDownloadsEn {
/// en: 'Delete all'
String get deleteAll => 'Delete all';
/// en: 'Select Version'
String get selectVersion => 'Select Version';
}
// Path: shaders
@@ -3564,6 +3567,7 @@ extension on Translations {
'downloads.pauseAll' => 'Pause all',
'downloads.resumeAll' => 'Resume all',
'downloads.deleteAll' => 'Delete all',
'downloads.selectVersion' => 'Select Version',
'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'No video enhancement',
'shaders.nvscalerDescription' => 'NVIDIA image scaling for sharper video',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsEs implements TranslationsDownloadsEn {
@override String get pauseAll => 'Pausar todo';
@override String get resumeAll => 'Reanudar todo';
@override String get deleteAll => 'Eliminar todo';
@override String get selectVersion => 'Seleccionar versión';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsEs {
'downloads.pauseAll' => 'Pausar todo',
'downloads.resumeAll' => 'Reanudar todo',
'downloads.deleteAll' => 'Eliminar todo',
'downloads.selectVersion' => 'Seleccionar versión',
'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'Sin mejora de video',
'shaders.nvscalerDescription' => 'Escalado de imagen NVIDIA para un video más nítido',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsFr implements TranslationsDownloadsEn {
@override String get pauseAll => 'Tout mettre en pause';
@override String get resumeAll => 'Tout reprendre';
@override String get deleteAll => 'Tout supprimer';
@override String get selectVersion => 'Sélectionner la version';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsFr {
'downloads.pauseAll' => 'Tout mettre en pause',
'downloads.resumeAll' => 'Tout reprendre',
'downloads.deleteAll' => 'Tout supprimer',
'downloads.selectVersion' => 'Sélectionner la version',
'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'Aucune amélioration vidéo',
'shaders.nvscalerDescription' => 'Mise à l\'échelle NVIDIA pour une vidéo plus nette',
+2
View File
@@ -841,6 +841,7 @@ class _TranslationsDownloadsIt implements TranslationsDownloadsEn {
@override String get pauseAll => 'Metti tutto in pausa';
@override String get resumeAll => 'Riprendi tutto';
@override String get deleteAll => 'Elimina tutto';
@override String get selectVersion => 'Seleziona versione';
}
// Path: playlists
@@ -1845,6 +1846,7 @@ extension on TranslationsIt {
'downloads.pauseAll' => 'Metti tutto in pausa',
'downloads.resumeAll' => 'Riprendi tutto',
'downloads.deleteAll' => 'Elimina tutto',
'downloads.selectVersion' => 'Seleziona versione',
'playlists.title' => 'Playlist',
'playlists.noPlaylists' => 'Nessuna playlist trovata',
'playlists.create' => 'Crea playlist',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsJa implements TranslationsDownloadsEn {
@override String get pauseAll => 'すべて一時停止';
@override String get resumeAll => 'すべて再開';
@override String get deleteAll => 'すべて削除';
@override String get selectVersion => 'バージョンを選択';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsJa {
'downloads.pauseAll' => 'すべて一時停止',
'downloads.resumeAll' => 'すべて再開',
'downloads.deleteAll' => 'すべて削除',
'downloads.selectVersion' => 'バージョンを選択',
'shaders.title' => 'シェーダー',
'shaders.noShaderDescription' => '映像補正なし',
'shaders.nvscalerDescription' => 'よりシャープな映像のためのNVIDIA画像スケーリング',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsKo implements TranslationsDownloadsEn {
@override String get pauseAll => '모두 일시정지';
@override String get resumeAll => '모두 재개';
@override String get deleteAll => '모두 삭제';
@override String get selectVersion => '버전 선택';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsKo {
'downloads.pauseAll' => '모두 일시정지',
'downloads.resumeAll' => '모두 재개',
'downloads.deleteAll' => '모두 삭제',
'downloads.selectVersion' => '버전 선택',
'shaders.title' => '셰이더',
'shaders.noShaderDescription' => '비디오 향상 없음',
'shaders.nvscalerDescription' => '더 선명한 비디오를 위한 NVIDIA 이미지 스케일링',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsNb implements TranslationsDownloadsEn {
@override String get pauseAll => 'Pause alle';
@override String get resumeAll => 'Gjenoppta alle';
@override String get deleteAll => 'Slett alle';
@override String get selectVersion => 'Velg versjon';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsNb {
'downloads.pauseAll' => 'Pause alle',
'downloads.resumeAll' => 'Gjenoppta alle',
'downloads.deleteAll' => 'Slett alle',
'downloads.selectVersion' => 'Velg versjon',
'shaders.title' => 'Shadere',
'shaders.noShaderDescription' => 'Ingen videoforbedring',
'shaders.nvscalerDescription' => 'NVIDIA bildeskalering for skarpere video',
+2
View File
@@ -841,6 +841,7 @@ class _TranslationsDownloadsNl implements TranslationsDownloadsEn {
@override String get pauseAll => 'Alles pauzeren';
@override String get resumeAll => 'Alles hervatten';
@override String get deleteAll => 'Alles verwijderen';
@override String get selectVersion => 'Versie selecteren';
}
// Path: playlists
@@ -1845,6 +1846,7 @@ extension on TranslationsNl {
'downloads.pauseAll' => 'Alles pauzeren',
'downloads.resumeAll' => 'Alles hervatten',
'downloads.deleteAll' => 'Alles verwijderen',
'downloads.selectVersion' => 'Versie selecteren',
'playlists.title' => 'Afspeellijsten',
'playlists.noPlaylists' => 'Geen afspeellijsten gevonden',
'playlists.create' => 'Afspeellijst maken',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsPl implements TranslationsDownloadsEn {
@override String get pauseAll => 'Wstrzymaj wszystko';
@override String get resumeAll => 'Wznów wszystko';
@override String get deleteAll => 'Usuń wszystko';
@override String get selectVersion => 'Wybierz wersję';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsPl {
'downloads.pauseAll' => 'Wstrzymaj wszystko',
'downloads.resumeAll' => 'Wznów wszystko',
'downloads.deleteAll' => 'Usuń wszystko',
'downloads.selectVersion' => 'Wybierz wersję',
'shaders.title' => 'Shadery',
'shaders.noShaderDescription' => 'Bez ulepszenia wideo',
'shaders.nvscalerDescription' => 'Skalowanie obrazu NVIDIA dla ostrzejszego wideo',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsPt implements TranslationsDownloadsEn {
@override String get pauseAll => 'Pausar todos';
@override String get resumeAll => 'Retomar todos';
@override String get deleteAll => 'Excluir todos';
@override String get selectVersion => 'Selecionar versão';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsPt {
'downloads.pauseAll' => 'Pausar todos',
'downloads.resumeAll' => 'Retomar todos',
'downloads.deleteAll' => 'Excluir todos',
'downloads.selectVersion' => 'Selecionar versão',
'shaders.title' => 'Shaders',
'shaders.noShaderDescription' => 'Sem aprimoramento de vídeo',
'shaders.nvscalerDescription' => 'Escalonamento de imagem NVIDIA para vídeo mais nítido',
+2
View File
@@ -964,6 +964,7 @@ class _TranslationsDownloadsRu implements TranslationsDownloadsEn {
@override String get pauseAll => 'Приостановить все';
@override String get resumeAll => 'Возобновить все';
@override String get deleteAll => 'Удалить все';
@override String get selectVersion => 'Выбрать версию';
}
// Path: shaders
@@ -1941,6 +1942,7 @@ extension on TranslationsRu {
'downloads.pauseAll' => 'Приостановить все',
'downloads.resumeAll' => 'Возобновить все',
'downloads.deleteAll' => 'Удалить все',
'downloads.selectVersion' => 'Выбрать версию',
'shaders.title' => 'Шейдеры',
'shaders.noShaderDescription' => 'Без улучшения видео',
'shaders.nvscalerDescription' => 'Масштабирование NVIDIA для более чёткого видео',
+2
View File
@@ -841,6 +841,7 @@ class _TranslationsDownloadsSv implements TranslationsDownloadsEn {
@override String get pauseAll => 'Pausa alla';
@override String get resumeAll => 'Återuppta alla';
@override String get deleteAll => 'Ta bort alla';
@override String get selectVersion => 'Välj version';
}
// Path: playlists
@@ -1845,6 +1846,7 @@ extension on TranslationsSv {
'downloads.pauseAll' => 'Pausa alla',
'downloads.resumeAll' => 'Återuppta alla',
'downloads.deleteAll' => 'Ta bort alla',
'downloads.selectVersion' => 'Välj version',
'playlists.title' => 'Spellistor',
'playlists.noPlaylists' => 'Inga spellistor hittades',
'playlists.create' => 'Skapa spellista',
+2
View File
@@ -841,6 +841,7 @@ class _TranslationsDownloadsZh implements TranslationsDownloadsEn {
@override String get pauseAll => '全部暂停';
@override String get resumeAll => '全部继续';
@override String get deleteAll => '全部删除';
@override String get selectVersion => '选择版本';
}
// Path: playlists
@@ -1845,6 +1846,7 @@ extension on TranslationsZh {
'downloads.pauseAll' => '全部暂停',
'downloads.resumeAll' => '全部继续',
'downloads.deleteAll' => '全部删除',
'downloads.selectVersion' => '选择版本',
'playlists.title' => '播放列表',
'playlists.noPlaylists' => '未找到播放列表',
'playlists.create' => '创建播放列表',
+2 -1
View File
@@ -601,7 +601,8 @@
"noDownloadsTree": "Inga nedladdningar",
"pauseAll": "Pausa alla",
"resumeAll": "Återuppta alla",
"deleteAll": "Ta bort alla"
"deleteAll": "Ta bort alla",
"selectVersion": "Välj version"
},
"playlists": {
"title": "Spellistor",
+2 -1
View File
@@ -601,7 +601,8 @@
"noDownloadsTree": "暂无下载",
"pauseAll": "全部暂停",
"resumeAll": "全部继续",
"deleteAll": "全部删除"
"deleteAll": "全部删除",
"selectVersion": "选择版本"
},
"playlists": {
"title": "播放列表",
+43
View File
@@ -72,6 +72,49 @@ class PlexMediaVersion {
return label;
}
/// Version signature for matching across episodes.
/// Format: "resolution:codec:container" (e.g., "1080:h264:mkv")
String get signature {
final res = videoResolution ?? '';
final codec = videoCodec ?? '';
final cont = container ?? '';
return '$res:$codec:$cont'.toLowerCase();
}
String get _resolutionPart => (videoResolution ?? '').toLowerCase();
String get _codecPart => (videoCodec ?? '').toLowerCase();
/// Find the best matching version index from a set of accepted signatures.
/// Uses tiered matching: exact → resolution+codec → resolution only.
/// Returns null if no accepted signature matches at all.
static int? findMatchingIndex(List<PlexMediaVersion> versions, Set<String> acceptedSignatures) {
if (versions.isEmpty || acceptedSignatures.isEmpty) return null;
for (final sig in acceptedSignatures) {
final parts = sig.split(':');
if (parts.length != 3) continue;
final targetRes = parts[0];
final targetCodec = parts[1];
// Tier 1: exact match
for (int i = 0; i < versions.length; i++) {
if (versions[i].signature == sig) return i;
}
// Tier 2: resolution + codec
for (int i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == targetRes && versions[i]._codecPart == targetCodec) return i;
}
// Tier 3: resolution only
for (int i = 0; i < versions.length; i++) {
if (versions[i]._resolutionPart == targetRes) return i;
}
}
return null;
}
@override
String toString() => displayLabel;
}
+63 -33
View File
@@ -4,7 +4,9 @@ import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:plezy/utils/content_utils.dart';
import '../models/download_models.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../utils/download_version_utils.dart';
import '../services/download_manager_service.dart';
import '../services/download_storage_service.dart';
import '../services/storage_service.dart';
@@ -593,8 +595,13 @@ class DownloadProvider extends ChangeNotifier {
/// For movies and episodes, queues directly.
/// For shows and seasons, fetches all child episodes and queues them.
/// Returns the number of items queued.
Future<int> queueDownload(PlexMetadata metadata, PlexClient client) async {
Future<int> queueDownload(
PlexMetadata metadata,
PlexClient client, {
DownloadVersionConfig? versionConfig,
}) async {
final globalKey = metadata.globalKey;
final config = versionConfig ?? DownloadVersionConfig();
// Check if downloads are blocked on cellular
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
@@ -609,40 +616,38 @@ class DownloadProvider extends ChangeNotifier {
final mt = metadata.mediaType;
if (mt == PlexMediaType.movie || mt == PlexMediaType.episode) {
// Direct download of a single item
await _queueSingleDownload(metadata, client);
return 1;
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
return queued ? 1 : 0;
} else if (mt == PlexMediaType.show) {
// Store show metadata so getProgress() can identify it as a show
_metadata[globalKey] = metadata;
// Download all episodes from all seasons
return await _queueShowDownload(metadata, client);
return await _queueShowDownload(metadata, client, versionConfig: config);
} else if (mt == PlexMediaType.season) {
// Store season metadata so getProgress() can identify it as a season
_metadata[globalKey] = metadata;
// Download all episodes in season
return await _queueSeasonDownload(metadata, client);
return await _queueSeasonDownload(metadata, client, versionConfig: config);
} else {
throw Exception('Cannot download ${metadata.type}');
}
} finally {
// Always remove from queueing set, even on error
_queueing.remove(globalKey);
notifyListeners();
}
}
/// Queue a single movie or episode for download
Future<void> _queueSingleDownload(PlexMetadata metadata, PlexClient client) async {
/// Queue a single movie or episode for download.
/// Returns true if the item was actually queued, false if skipped.
Future<bool> _queueSingleDownload(
PlexMetadata metadata,
PlexClient client, {
int mediaIndex = 0,
DownloadVersionConfig? versionConfig,
}) async {
final globalKey = metadata.globalKey;
// Don't re-queue if already downloading or completed
if (_downloads.containsKey(globalKey)) {
final existing = _downloads[globalKey]!;
if (existing.status == DownloadStatus.downloading || existing.status == DownloadStatus.completed) {
return;
return false;
}
}
@@ -660,6 +665,23 @@ class DownloadProvider extends ChangeNotifier {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
}
// Smart version matching for series/season downloads
var resolvedIndex = mediaIndex;
if (versionConfig != null && versionConfig.acceptedSignatures.isNotEmpty) {
final versions = metadataToStore.mediaVersions;
if (versions != null && versions.isNotEmpty) {
final matchedIndex = PlexMediaVersion.findMatchingIndex(versions, versionConfig.acceptedSignatures);
if (matchedIndex != null) {
resolvedIndex = matchedIndex;
} else if (versionConfig.onVersionMismatch != null) {
final pickedIndex = await versionConfig.onVersionMismatch!(metadataToStore, versions);
if (pickedIndex == null) return false;
resolvedIndex = pickedIndex;
versionConfig.acceptedSignatures.add(versions[pickedIndex].signature);
}
}
}
// For episodes, also fetch and store show and season metadata for offline display
if (metadataToStore.type == 'episode') {
await _fetchAndStoreParentMetadata(metadataToStore, client);
@@ -673,7 +695,8 @@ class DownloadProvider extends ChangeNotifier {
notifyListeners();
// Actually trigger download via DownloadManagerService
await _downloadManager.queueDownload(metadata: metadataToStore, client: client);
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
return true;
}
/// Fetch and store show and season metadata for an episode
@@ -727,7 +750,7 @@ class DownloadProvider extends ChangeNotifier {
}
/// Queue all episodes from a TV show for download
Future<int> _queueShowDownload(PlexMetadata show, PlexClient client) async {
Future<int> _queueShowDownload(PlexMetadata show, PlexClient client, {DownloadVersionConfig? versionConfig}) async {
int count = 0;
final seasons = await client.getChildren(show.ratingKey);
@@ -736,7 +759,7 @@ class DownloadProvider extends ChangeNotifier {
for (final season in seasons) {
if (season.type == 'season') {
final seasonWithServer = _ensureServerId(season, show.serverId);
count += await _queueSeasonDownload(seasonWithServer, client);
count += await _queueSeasonDownload(seasonWithServer, client, versionConfig: versionConfig);
}
}
@@ -744,7 +767,8 @@ class DownloadProvider extends ChangeNotifier {
}
/// Queue all episodes from a season for download
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client) async {
Future<int> _queueSeasonDownload(PlexMetadata season, PlexClient client,
{DownloadVersionConfig? versionConfig}) async {
int count = 0;
final episodes = await client.getChildren(season.ratingKey);
@@ -753,8 +777,8 @@ class DownloadProvider extends ChangeNotifier {
for (final episode in episodes) {
if (episode.type == 'episode') {
final episodeWithServer = _ensureServerId(episode, season.serverId);
await _queueSingleDownload(episodeWithServer, client);
count++;
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig);
if (queued) count++;
}
}
@@ -764,29 +788,33 @@ class DownloadProvider extends ChangeNotifier {
/// Queue only the missing (not downloaded) episodes for a show/season
/// Used for resuming partial downloads
/// Returns the number of episodes queued
Future<int> queueMissingEpisodes(PlexMetadata metadata, PlexClient client) async {
Future<int> queueMissingEpisodes(
PlexMetadata metadata,
PlexClient client, {
DownloadVersionConfig? versionConfig,
}) async {
final mt = metadata.mediaType;
if (mt == PlexMediaType.show) {
return await _queueMissingShowEpisodes(metadata, client);
return await _queueMissingShowEpisodes(metadata, client, versionConfig: versionConfig);
} else if (mt == PlexMediaType.season) {
return await _queueMissingSeasonEpisodes(metadata, client);
return await _queueMissingSeasonEpisodes(metadata, client, versionConfig: versionConfig);
} else {
throw Exception('queueMissingEpisodes only supports shows/seasons');
}
}
/// Queue missing episodes for a show
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client) async {
Future<int> _queueMissingShowEpisodes(PlexMetadata show, PlexClient client,
{DownloadVersionConfig? versionConfig}) async {
int queuedCount = 0;
// Fetch all seasons
final seasons = await client.getChildren(show.ratingKey);
for (final season in seasons) {
if (season.type == 'season') {
final seasonWithServer = _ensureServerId(season, show.serverId);
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client);
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client, versionConfig: versionConfig);
}
}
@@ -795,10 +823,10 @@ class DownloadProvider extends ChangeNotifier {
}
/// Queue missing episodes for a season
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client) async {
Future<int> _queueMissingSeasonEpisodes(PlexMetadata season, PlexClient client,
{DownloadVersionConfig? versionConfig}) async {
int queuedCount = 0;
// Fetch all episodes
final episodes = await client.getChildren(season.ratingKey);
for (final episode in episodes) {
@@ -813,9 +841,11 @@ class DownloadProvider extends ChangeNotifier {
(progress.status != DownloadStatus.completed &&
progress.status != DownloadStatus.downloading &&
progress.status != DownloadStatus.queued)) {
await _queueSingleDownload(episodeWithServer, client);
queuedCount++;
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig);
if (queued) {
queuedCount++;
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
}
}
}
}
+37 -6
View File
@@ -29,6 +29,7 @@ import '../utils/content_utils.dart';
import '../utils/rating_utils.dart';
import '../models/download_models.dart';
import '../services/download_storage_service.dart';
import '../utils/download_version_utils.dart';
import '../providers/playback_state_provider.dart';
import '../providers/download_provider.dart';
import '../providers/offline_watch_provider.dart';
@@ -640,10 +641,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final client = _getClientForMetadata(context);
if (client == null) return;
// Delete failed download and retry
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return;
await downloadProvider.deleteDownload(globalKey);
try {
await downloadProvider.queueDownload(metadata, client);
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
if (context.mounted) {
showSuccessSnackBar(context, t.downloads.downloadQueued);
@@ -682,9 +685,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} else if (retry && context.mounted) {
final client = _getClientForMetadata(context);
if (client == null) return;
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return;
await downloadProvider.deleteDownload(globalKey);
try {
await downloadProvider.queueDownload(metadata, client);
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
if (context.mounted) {
showSuccessSnackBar(context, t.downloads.downloadQueued);
}
@@ -714,8 +721,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final client = _getClientForMetadata(context);
if (client == null) return;
// Queue only the missing episodes
final count = await downloadProvider.queueMissingEpisodes(metadata, client);
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return;
final count = await downloadProvider.queueMissingEpisodes(
metadata,
client,
versionConfig: versionConfig,
);
if (context.mounted) {
final message = count > 0
@@ -761,8 +774,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
onPressed: () async {
final client = _getClientForMetadata(context);
if (client == null) return;
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
if (versionConfig == null || !context.mounted) return;
try {
final count = await downloadProvider.queueDownload(metadata, client);
final count = await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
if (context.mounted) {
final message = count > 1
? t.downloads.episodesQueued(count: count)
@@ -1058,6 +1075,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return getServerBoundClient(context);
}
/// Resolve version selection for download using shared utility.
Future<DownloadVersionConfig?> _resolveDownloadVersion(
BuildContext context,
PlexMetadata metadata,
PlexClient client,
) {
return resolveDownloadVersion(
context,
metadata,
client,
fallbackVersions: _fullMetadata?.mediaVersions,
);
}
Future<void> _loadFullMetadata() async {
setState(() {
_isLoadingMetadata = true;
+5 -2
View File
@@ -329,6 +329,7 @@ class DownloadManagerService {
int priority = 0,
bool downloadSubtitles = true,
bool downloadArtwork = true,
int mediaIndex = 0,
}) async {
final globalKey = metadata.globalKey;
@@ -349,6 +350,7 @@ class DownloadManagerService {
parentRatingKey: metadata.parentRatingKey,
grandparentRatingKey: metadata.grandparentRatingKey,
status: DownloadStatus.queued.index,
mediaIndex: mediaIndex,
);
// Ensure metadata is in cache before pinning.
@@ -430,14 +432,15 @@ class DownloadManagerService {
}
}
var playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
final selectedMediaIndex = existing.mediaIndex;
var playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
if (playbackData.videoUrl == null) {
// Cache may contain a synthetic entry (from _cacheMetadataForOffline) without
// Media/Part data. Force a fresh network fetch to populate the cache properly.
appLogger.w('No video URL from cache for $globalKey, retrying via network');
final fetched = await client.getMetadataWithImages(ratingKey);
if (fetched != null) metadata = fetched.copyWith(serverId: serverId);
playbackData = await client.getVideoPlaybackData(metadata.ratingKey);
playbackData = await client.getVideoPlaybackData(metadata.ratingKey, mediaIndex: selectedMediaIndex);
if (playbackData.videoUrl == null) throw Exception('Could not get video URL for $globalKey');
}
+139
View File
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/plex_media_version.dart';
import '../models/plex_metadata.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../widgets/app_icon.dart';
import '../i18n/strings.g.dart';
/// Configuration for download version selection, threaded through the queue pipeline.
class DownloadVersionConfig {
final int mediaIndex;
final Set<String> acceptedSignatures;
final Future<int?> Function(PlexMetadata episode, List<PlexMediaVersion> versions)? onVersionMismatch;
DownloadVersionConfig({
this.mediaIndex = 0,
Set<String>? acceptedSignatures,
this.onVersionMismatch,
}) : acceptedSignatures = acceptedSignatures ?? {};
/// Create from a selected version's signature.
factory DownloadVersionConfig.fromSignature(
String signature, {
int mediaIndex = 0,
Future<int?> Function(PlexMetadata, List<PlexMediaVersion>)? onVersionMismatch,
}) {
return DownloadVersionConfig(
mediaIndex: mediaIndex,
acceptedSignatures: {signature},
onVersionMismatch: onVersionMismatch,
);
}
}
/// Resolve version selection for a download. Shows picker if needed.
/// Returns null if the user cancels, or a config with the selection.
Future<DownloadVersionConfig?> resolveDownloadVersion(
BuildContext context,
PlexMetadata metadata,
PlexClient client, {
List<PlexMediaVersion>? fallbackVersions,
}) async {
final mediaType = metadata.mediaType;
if (mediaType == PlexMediaType.movie || mediaType == PlexMediaType.episode) {
final versions = metadata.mediaVersions ?? fallbackVersions;
if (versions != null && versions.length > 1) {
final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion);
if (selectedIndex == null || !context.mounted) return null;
return DownloadVersionConfig(mediaIndex: selectedIndex);
}
return DownloadVersionConfig();
}
if (mediaType == PlexMediaType.show || mediaType == PlexMediaType.season) {
final versions = await fetchRepresentativeVersions(client, metadata);
if (versions != null && versions.length > 1) {
if (!context.mounted) return null;
final selectedIndex = await showVersionPickerDialog(context, versions, t.downloads.selectVersion);
if (selectedIndex == null || !context.mounted) return null;
return DownloadVersionConfig.fromSignature(
versions[selectedIndex].signature,
mediaIndex: selectedIndex,
onVersionMismatch: (episode, episodeVersions) async {
if (!context.mounted) return null;
return showVersionPickerDialog(
context,
episodeVersions,
'${episode.displayTitle} - ${t.downloads.selectVersion}',
);
},
);
}
return DownloadVersionConfig();
}
return DownloadVersionConfig();
}
/// Show a dialog for selecting a media version.
/// Returns the selected index, or null if cancelled.
Future<int?> showVersionPickerDialog(BuildContext context, List<PlexMediaVersion> versions, String title) {
return showDialog<int>(
context: context,
builder: (dialogContext) => SimpleDialog(
title: Text(title),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: List.generate(versions.length, (index) {
final version = versions[index];
return SimpleDialogOption(
onPressed: () => Navigator.pop(dialogContext, index),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
AppIcon(Symbols.video_file_rounded, fill: 1, size: 24),
const SizedBox(width: 16),
Text(version.displayLabel, style: Theme.of(dialogContext).textTheme.bodyLarge),
],
),
);
}),
),
);
}
/// Fetch media versions from a representative episode (first episode of first season).
Future<List<PlexMediaVersion>?> fetchRepresentativeVersions(PlexClient client, PlexMetadata metadata) async {
try {
String? episodeRatingKey;
if (metadata.mediaType == PlexMediaType.season) {
final episodes = await client.getChildren(metadata.ratingKey);
final firstEpisode = episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
episodeRatingKey = firstEpisode?.ratingKey;
} else if (metadata.mediaType == PlexMediaType.show) {
final seasons = await client.getChildren(metadata.ratingKey);
// Skip Season 0 (Specials) as it may have different encoding
final firstSeason = seasons.cast<PlexMetadata?>().firstWhere(
(s) => s?.type == 'season' && (s?.index ?? 0) > 0,
orElse: () => seasons.cast<PlexMetadata?>().firstWhere((s) => s?.type == 'season', orElse: () => null),
);
if (firstSeason != null) {
final episodes = await client.getChildren(firstSeason.ratingKey);
final firstEpisode =
episodes.cast<PlexMetadata?>().firstWhere((e) => e?.type == 'episode', orElse: () => null);
episodeRatingKey = firstEpisode?.ratingKey;
}
}
if (episodeRatingKey == null) return null;
final fullMetadata = await client.getMetadataWithImages(episodeRatingKey);
return fullMetadata?.mediaVersions;
} catch (e) {
appLogger.w('Failed to fetch representative versions', error: e);
return null;
}
}
+7 -23
View File
@@ -7,6 +7,7 @@ import '../services/plex_client.dart';
import '../services/play_queue_launcher.dart';
import '../models/plex_metadata.dart';
import '../models/plex_playlist.dart';
import '../utils/download_version_utils.dart';
import '../utils/content_utils.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart';
@@ -647,27 +648,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final metadata = widget.item as PlexMetadata;
final versions = metadata.mediaVersions!;
final selectedIndex = await showDialog<int>(
context: context,
builder: (dialogContext) => SimpleDialog(
title: Text(t.mediaMenu.playVersion),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
children: List.generate(versions.length, (index) {
final version = versions[index];
return SimpleDialogOption(
onPressed: () => Navigator.pop(dialogContext, index),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
children: [
AppIcon(Symbols.video_file_rounded, fill: 1, size: 24),
const SizedBox(width: 16),
Text(version.displayLabel, style: Theme.of(dialogContext).textTheme.bodyLarge),
],
),
);
}),
),
);
final selectedIndex = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion);
if (selectedIndex != null && context.mounted) {
await navigateToVideoPlayer(context, metadata: metadata, selectedMediaIndex: selectedIndex);
@@ -1146,9 +1127,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final client = _getClientForItem();
try {
final count = await downloadProvider.queueDownload(metadata, client);
final versionConfig = await resolveDownloadVersion(context, metadata, client);
if (versionConfig == null) return;
if (!context.mounted) return;
final count = await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
if (context.mounted) {
// Show appropriate message based on count
final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued;
showSuccessSnackBar(context, message);
}