refactor: trakt/download hardening and cleanup sweep

This commit is contained in:
edde746
2026-04-21 13:57:03 +02:00
parent 93d196572f
commit ee020d81bd
66 changed files with 1526 additions and 2167 deletions
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -136,8 +136,8 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
bool _ownsNode = false; bool _ownsNode = false;
bool _isFocused = false; bool _isFocused = false;
late AnimationController _animationController; late final AnimationController _animationController;
late Animation<double> _scaleAnimation; late final Animation<double> _scaleAnimation;
// Long-press detection for SELECT key // Long-press detection for SELECT key
Timer? _longPressTimer; Timer? _longPressTimer;
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Fejl ved indlæsning af serie", "errorLoadingSeries": "Fejl ved indlæsning af serie",
"errorLoadingSeason": "Fejl ved indlæsning af sæson", "errorLoadingSeason": "Fejl ved indlæsning af sæson",
"musicNotSupported": "Musikafspilning understøttes endnu ikke", "musicNotSupported": "Musikafspilning understøttes endnu ikke",
"noDescriptionAvailable": "Ingen beskrivelse tilgængelig",
"noProfilesAvailable": "Ingen profiler tilgængelige",
"contactAdminForProfiles": "Kontakt din Plex-administrator for at tilføje profiler",
"unableToDetermineLibrarySection": "Kan ikke bestemme biblioteksafdeling for dette element",
"logsCleared": "Logs ryddet", "logsCleared": "Logs ryddet",
"logsCopied": "Logs kopieret til udklipsholder", "logsCopied": "Logs kopieret til udklipsholder",
"noLogsAvailable": "Ingen logs tilgængelige", "noLogsAvailable": "Ingen logs tilgængelige",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Fehler beim Laden der Serie", "errorLoadingSeries": "Fehler beim Laden der Serie",
"errorLoadingSeason": "Fehler beim Laden der Staffel", "errorLoadingSeason": "Fehler beim Laden der Staffel",
"musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt", "musicNotSupported": "Musikwiedergabe wird noch nicht unterstützt",
"noDescriptionAvailable": "Keine Beschreibung verfügbar",
"noProfilesAvailable": "Keine Profile verfügbar",
"contactAdminForProfiles": "Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen",
"unableToDetermineLibrarySection": "Bibliotheksbereich für dieses Element kann nicht ermittelt werden",
"logsCleared": "Protokolle gelöscht", "logsCleared": "Protokolle gelöscht",
"logsCopied": "Protokolle in Zwischenablage kopiert", "logsCopied": "Protokolle in Zwischenablage kopiert",
"noLogsAvailable": "Keine Protokolle verfügbar", "noLogsAvailable": "Keine Protokolle verfügbar",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Error loading series", "errorLoadingSeries": "Error loading series",
"errorLoadingSeason": "Error loading season", "errorLoadingSeason": "Error loading season",
"musicNotSupported": "Music playback is not yet supported", "musicNotSupported": "Music playback is not yet supported",
"noDescriptionAvailable": "No description available",
"noProfilesAvailable": "No profiles available",
"contactAdminForProfiles": "Contact your Plex administrator to add profiles",
"unableToDetermineLibrarySection": "Unable to determine library section for this item",
"logsCleared": "Logs cleared", "logsCleared": "Logs cleared",
"logsCopied": "Logs copied to clipboard", "logsCopied": "Logs copied to clipboard",
"noLogsAvailable": "No logs available", "noLogsAvailable": "No logs available",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Error al cargar la serie", "errorLoadingSeries": "Error al cargar la serie",
"errorLoadingSeason": "Error al cargar la temporada", "errorLoadingSeason": "Error al cargar la temporada",
"musicNotSupported": "La reproducción de música aún no está soportada", "musicNotSupported": "La reproducción de música aún no está soportada",
"noDescriptionAvailable": "No hay descripción disponible",
"noProfilesAvailable": "No hay perfiles disponibles",
"contactAdminForProfiles": "Contacta con tu administrador de Plex para añadir perfiles",
"unableToDetermineLibrarySection": "No se puede determinar la sección de biblioteca para este elemento",
"logsCleared": "Logs borrados", "logsCleared": "Logs borrados",
"logsCopied": "Logs copiados al portapapeles", "logsCopied": "Logs copiados al portapapeles",
"noLogsAvailable": "No hay logs disponibles", "noLogsAvailable": "No hay logs disponibles",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Erreur lors du chargement de la série", "errorLoadingSeries": "Erreur lors du chargement de la série",
"errorLoadingSeason": "Erreur lors du chargement de la saison", "errorLoadingSeason": "Erreur lors du chargement de la saison",
"musicNotSupported": "La lecture de musique n'est pas encore prise en charge", "musicNotSupported": "La lecture de musique n'est pas encore prise en charge",
"noDescriptionAvailable": "Aucune description disponible",
"noProfilesAvailable": "Aucun profil disponible",
"contactAdminForProfiles": "Contactez votre administrateur Plex pour ajouter des profils",
"unableToDetermineLibrarySection": "Impossible de déterminer la section de la bibliothèque pour cet élément",
"logsCleared": "Logs effacés", "logsCleared": "Logs effacés",
"logsCopied": "Logs copiés dans le presse-papier", "logsCopied": "Logs copiés dans le presse-papier",
"noLogsAvailable": "Aucun log disponible", "noLogsAvailable": "Aucun log disponible",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Errore caricamento serie", "errorLoadingSeries": "Errore caricamento serie",
"errorLoadingSeason": "Errore caricamento stagione", "errorLoadingSeason": "Errore caricamento stagione",
"musicNotSupported": "La riproduzione musicale non è ancora supportata", "musicNotSupported": "La riproduzione musicale non è ancora supportata",
"noDescriptionAvailable": "Nessuna descrizione disponibile",
"noProfilesAvailable": "Nessun profilo disponibile",
"contactAdminForProfiles": "Contatta il tuo amministratore Plex per aggiungere profili",
"unableToDetermineLibrarySection": "Impossibile determinare la sezione della libreria per questo elemento",
"logsCleared": "Log eliminati", "logsCleared": "Log eliminati",
"logsCopied": "Log copiati negli appunti", "logsCopied": "Log copiati negli appunti",
"noLogsAvailable": "Nessun log disponibile", "noLogsAvailable": "Nessun log disponibile",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "シリーズの読み込みエラー", "errorLoadingSeries": "シリーズの読み込みエラー",
"errorLoadingSeason": "シーズンの読み込みエラー", "errorLoadingSeason": "シーズンの読み込みエラー",
"musicNotSupported": "音楽の再生はまだサポートされていません", "musicNotSupported": "音楽の再生はまだサポートされていません",
"noDescriptionAvailable": "説明はありません",
"noProfilesAvailable": "利用可能なプロフィールがありません",
"contactAdminForProfiles": "プロフィールを追加するにはPlex管理者にお問い合わせください",
"unableToDetermineLibrarySection": "このアイテムのライブラリセクションを判別できません",
"logsCleared": "ログをクリアしました", "logsCleared": "ログをクリアしました",
"logsCopied": "ログをクリップボードにコピーしました", "logsCopied": "ログをクリップボードにコピーしました",
"noLogsAvailable": "ログがありません", "noLogsAvailable": "ログがありません",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "시리즈 로딩 중 오류", "errorLoadingSeries": "시리즈 로딩 중 오류",
"errorLoadingSeason": "시즌 로딩 중 오류", "errorLoadingSeason": "시즌 로딩 중 오류",
"musicNotSupported": "음악 재생 미지원", "musicNotSupported": "음악 재생 미지원",
"noDescriptionAvailable": "설명이 없습니다",
"noProfilesAvailable": "사용 가능한 프로필이 없습니다",
"contactAdminForProfiles": "프로필을 추가하려면 Plex 관리자에게 문의하세요",
"unableToDetermineLibrarySection": "이 항목의 라이브러리 섹션을 확인할 수 없습니다",
"logsCleared": "로그가 삭제 되었습니다", "logsCleared": "로그가 삭제 되었습니다",
"logsCopied": "로그가 클립보드에 복사 되었습니다", "logsCopied": "로그가 클립보드에 복사 되었습니다",
"noLogsAvailable": "사용 가능한 로그가 없습니다", "noLogsAvailable": "사용 가능한 로그가 없습니다",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Feil ved lasting av serie", "errorLoadingSeries": "Feil ved lasting av serie",
"errorLoadingSeason": "Feil ved lasting av sesong", "errorLoadingSeason": "Feil ved lasting av sesong",
"musicNotSupported": "Musikkavspilling støttes ikke ennå", "musicNotSupported": "Musikkavspilling støttes ikke ennå",
"noDescriptionAvailable": "Ingen beskrivelse tilgjengelig",
"noProfilesAvailable": "Ingen profiler tilgjengelige",
"contactAdminForProfiles": "Kontakt Plex-administratoren for å legge til profiler",
"unableToDetermineLibrarySection": "Kan ikke fastslå bibliotekseksjonen for dette elementet",
"logsCleared": "Logger tømt", "logsCleared": "Logger tømt",
"logsCopied": "Logger kopiert til utklippstavle", "logsCopied": "Logger kopiert til utklippstavle",
"noLogsAvailable": "Ingen logger tilgjengelig", "noLogsAvailable": "Ingen logger tilgjengelig",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Fout bij laden serie", "errorLoadingSeries": "Fout bij laden serie",
"errorLoadingSeason": "Fout bij laden seizoen", "errorLoadingSeason": "Fout bij laden seizoen",
"musicNotSupported": "Muziek afspelen wordt nog niet ondersteund", "musicNotSupported": "Muziek afspelen wordt nog niet ondersteund",
"noDescriptionAvailable": "Geen beschrijving beschikbaar",
"noProfilesAvailable": "Geen profielen beschikbaar",
"contactAdminForProfiles": "Neem contact op met je Plex-beheerder om profielen toe te voegen",
"unableToDetermineLibrarySection": "Kan bibliotheeksectie voor dit item niet bepalen",
"logsCleared": "Logs gewist", "logsCleared": "Logs gewist",
"logsCopied": "Logs gekopieerd naar klembord", "logsCopied": "Logs gekopieerd naar klembord",
"noLogsAvailable": "Geen logs beschikbaar", "noLogsAvailable": "Geen logs beschikbaar",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Błąd ładowania serialu", "errorLoadingSeries": "Błąd ładowania serialu",
"errorLoadingSeason": "Błąd ładowania sezonu", "errorLoadingSeason": "Błąd ładowania sezonu",
"musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane", "musicNotSupported": "Odtwarzanie muzyki nie jest jeszcze obsługiwane",
"noDescriptionAvailable": "Brak dostępnego opisu",
"noProfilesAvailable": "Brak dostępnych profili",
"contactAdminForProfiles": "Skontaktuj się z administratorem Plex, aby dodać profile",
"unableToDetermineLibrarySection": "Nie można określić sekcji biblioteki dla tego elementu",
"logsCleared": "Logi wyczyszczone", "logsCleared": "Logi wyczyszczone",
"logsCopied": "Logi skopiowane do schowka", "logsCopied": "Logi skopiowane do schowka",
"noLogsAvailable": "Brak dostępnych logów", "noLogsAvailable": "Brak dostępnych logów",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Erro ao carregar série", "errorLoadingSeries": "Erro ao carregar série",
"errorLoadingSeason": "Erro ao carregar temporada", "errorLoadingSeason": "Erro ao carregar temporada",
"musicNotSupported": "Reprodução de música ainda não é suportada", "musicNotSupported": "Reprodução de música ainda não é suportada",
"noDescriptionAvailable": "Nenhuma descrição disponível",
"noProfilesAvailable": "Nenhum perfil disponível",
"contactAdminForProfiles": "Contacte o seu administrador Plex para adicionar perfis",
"unableToDetermineLibrarySection": "Não é possível determinar a secção da biblioteca para este item",
"logsCleared": "Logs limpos", "logsCleared": "Logs limpos",
"logsCopied": "Logs copiados para a área de transferência", "logsCopied": "Logs copiados para a área de transferência",
"noLogsAvailable": "Nenhum log disponível", "noLogsAvailable": "Nenhum log disponível",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Ошибка загрузки сериала", "errorLoadingSeries": "Ошибка загрузки сериала",
"errorLoadingSeason": "Ошибка загрузки сезона", "errorLoadingSeason": "Ошибка загрузки сезона",
"musicNotSupported": "Воспроизведение музыки пока не поддерживается", "musicNotSupported": "Воспроизведение музыки пока не поддерживается",
"noDescriptionAvailable": "Описание недоступно",
"noProfilesAvailable": "Профили недоступны",
"contactAdminForProfiles": "Обратитесь к администратору Plex, чтобы добавить профили",
"unableToDetermineLibrarySection": "Не удаётся определить раздел библиотеки для этого элемента",
"logsCleared": "Логи очищены", "logsCleared": "Логи очищены",
"logsCopied": "Логи скопированы в буфер обмена", "logsCopied": "Логи скопированы в буфер обмена",
"noLogsAvailable": "Логи отсутствуют", "noLogsAvailable": "Логи отсутствуют",
+2 -2
View File
@@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 15 /// Locales: 15
/// Strings: 13470 (898 per locale) /// Strings: 13530 (902 per locale)
/// ///
/// Built on 2026-04-20 at 16:17 UTC /// Built on 2026-04-21 at 11:42 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesDa implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Fejl ved indlæsning af serie'; @override String get errorLoadingSeries => 'Fejl ved indlæsning af serie';
@override String get errorLoadingSeason => 'Fejl ved indlæsning af sæson'; @override String get errorLoadingSeason => 'Fejl ved indlæsning af sæson';
@override String get musicNotSupported => 'Musikafspilning understøttes endnu ikke'; @override String get musicNotSupported => 'Musikafspilning understøttes endnu ikke';
@override String get noDescriptionAvailable => 'Ingen beskrivelse tilgængelig';
@override String get noProfilesAvailable => 'Ingen profiler tilgængelige';
@override String get contactAdminForProfiles => 'Kontakt din Plex-administrator for at tilføje profiler';
@override String get unableToDetermineLibrarySection => 'Kan ikke bestemme biblioteksafdeling for dette element';
@override String get logsCleared => 'Logs ryddet'; @override String get logsCleared => 'Logs ryddet';
@override String get logsCopied => 'Logs kopieret til udklipsholder'; @override String get logsCopied => 'Logs kopieret til udklipsholder';
@override String get noLogsAvailable => 'Ingen logs tilgængelige'; @override String get noLogsAvailable => 'Ingen logs tilgængelige';
@@ -1799,6 +1803,10 @@ extension on TranslationsDa {
'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie', 'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie',
'messages.errorLoadingSeason' => 'Fejl ved indlæsning af sæson', 'messages.errorLoadingSeason' => 'Fejl ved indlæsning af sæson',
'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke', 'messages.musicNotSupported' => 'Musikafspilning understøttes endnu ikke',
'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgængelig',
'messages.noProfilesAvailable' => 'Ingen profiler tilgængelige',
'messages.contactAdminForProfiles' => 'Kontakt din Plex-administrator for at tilføje profiler',
'messages.unableToDetermineLibrarySection' => 'Kan ikke bestemme biblioteksafdeling for dette element',
'messages.logsCleared' => 'Logs ryddet', 'messages.logsCleared' => 'Logs ryddet',
'messages.logsCopied' => 'Logs kopieret til udklipsholder', 'messages.logsCopied' => 'Logs kopieret til udklipsholder',
'messages.noLogsAvailable' => 'Ingen logs tilgængelige', 'messages.noLogsAvailable' => 'Ingen logs tilgængelige',
@@ -1913,12 +1921,12 @@ extension on TranslationsDa {
'libraries.noFoldersFound' => 'Ingen mapper fundet', 'libraries.noFoldersFound' => 'Ingen mapper fundet',
'libraries.folders' => 'mapper', 'libraries.folders' => 'mapper',
'libraries.tabs.recommended' => 'Anbefalet', 'libraries.tabs.recommended' => 'Anbefalet',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Gennemse', 'libraries.tabs.browse' => 'Gennemse',
'libraries.tabs.collections' => 'Samlinger', 'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Playlister', 'libraries.tabs.playlists' => 'Playlister',
'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.title' => 'Gruppering',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Film', 'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'TV-serier', 'libraries.groupings.shows' => 'TV-serier',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Fehler beim Laden der Serie'; @override String get errorLoadingSeries => 'Fehler beim Laden der Serie';
@override String get errorLoadingSeason => 'Fehler beim Laden der Staffel'; @override String get errorLoadingSeason => 'Fehler beim Laden der Staffel';
@override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt'; @override String get musicNotSupported => 'Musikwiedergabe wird noch nicht unterstützt';
@override String get noDescriptionAvailable => 'Keine Beschreibung verfügbar';
@override String get noProfilesAvailable => 'Keine Profile verfügbar';
@override String get contactAdminForProfiles => 'Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen';
@override String get unableToDetermineLibrarySection => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden';
@override String get logsCleared => 'Protokolle gelöscht'; @override String get logsCleared => 'Protokolle gelöscht';
@override String get logsCopied => 'Protokolle in Zwischenablage kopiert'; @override String get logsCopied => 'Protokolle in Zwischenablage kopiert';
@override String get noLogsAvailable => 'Keine Protokolle verfügbar'; @override String get noLogsAvailable => 'Keine Protokolle verfügbar';
@@ -1799,6 +1803,10 @@ extension on TranslationsDe {
'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie', 'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie',
'messages.errorLoadingSeason' => 'Fehler beim Laden der Staffel', 'messages.errorLoadingSeason' => 'Fehler beim Laden der Staffel',
'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt', 'messages.musicNotSupported' => 'Musikwiedergabe wird noch nicht unterstützt',
'messages.noDescriptionAvailable' => 'Keine Beschreibung verfügbar',
'messages.noProfilesAvailable' => 'Keine Profile verfügbar',
'messages.contactAdminForProfiles' => 'Kontaktiere deinen Plex-Administrator, um Profile hinzuzufügen',
'messages.unableToDetermineLibrarySection' => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden',
'messages.logsCleared' => 'Protokolle gelöscht', 'messages.logsCleared' => 'Protokolle gelöscht',
'messages.logsCopied' => 'Protokolle in Zwischenablage kopiert', 'messages.logsCopied' => 'Protokolle in Zwischenablage kopiert',
'messages.noLogsAvailable' => 'Keine Protokolle verfügbar', 'messages.noLogsAvailable' => 'Keine Protokolle verfügbar',
@@ -1913,12 +1921,12 @@ extension on TranslationsDe {
'libraries.noFoldersFound' => 'Keine Ordner gefunden', 'libraries.noFoldersFound' => 'Keine Ordner gefunden',
'libraries.folders' => 'Ordner', 'libraries.folders' => 'Ordner',
'libraries.tabs.recommended' => 'Empfohlen', 'libraries.tabs.recommended' => 'Empfohlen',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Durchsuchen', 'libraries.tabs.browse' => 'Durchsuchen',
'libraries.tabs.collections' => 'Sammlungen', 'libraries.tabs.collections' => 'Sammlungen',
'libraries.tabs.playlists' => 'Wiedergabelisten', 'libraries.tabs.playlists' => 'Wiedergabelisten',
'libraries.groupings.title' => 'Gruppierung', 'libraries.groupings.title' => 'Gruppierung',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filme', 'libraries.groupings.movies' => 'Filme',
'libraries.groupings.shows' => 'Serien', 'libraries.groupings.shows' => 'Serien',
+18 -2
View File
@@ -1317,6 +1317,18 @@ class TranslationsMessagesEn {
/// en: 'Music playback is not yet supported' /// en: 'Music playback is not yet supported'
String get musicNotSupported => 'Music playback is not yet supported'; String get musicNotSupported => 'Music playback is not yet supported';
/// en: 'No description available'
String get noDescriptionAvailable => 'No description available';
/// en: 'No profiles available'
String get noProfilesAvailable => 'No profiles available';
/// en: 'Contact your Plex administrator to add profiles'
String get contactAdminForProfiles => 'Contact your Plex administrator to add profiles';
/// en: 'Unable to determine library section for this item'
String get unableToDetermineLibrarySection => 'Unable to determine library section for this item';
/// en: 'Logs cleared' /// en: 'Logs cleared'
String get logsCleared => 'Logs cleared'; String get logsCleared => 'Logs cleared';
@@ -3602,6 +3614,10 @@ extension on Translations {
'messages.errorLoadingSeries' => 'Error loading series', 'messages.errorLoadingSeries' => 'Error loading series',
'messages.errorLoadingSeason' => 'Error loading season', 'messages.errorLoadingSeason' => 'Error loading season',
'messages.musicNotSupported' => 'Music playback is not yet supported', 'messages.musicNotSupported' => 'Music playback is not yet supported',
'messages.noDescriptionAvailable' => 'No description available',
'messages.noProfilesAvailable' => 'No profiles available',
'messages.contactAdminForProfiles' => 'Contact your Plex administrator to add profiles',
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
'messages.logsCleared' => 'Logs cleared', 'messages.logsCleared' => 'Logs cleared',
'messages.logsCopied' => 'Logs copied to clipboard', 'messages.logsCopied' => 'Logs copied to clipboard',
'messages.noLogsAvailable' => 'No logs available', 'messages.noLogsAvailable' => 'No logs available',
@@ -3716,12 +3732,12 @@ extension on Translations {
'libraries.noFoldersFound' => 'No folders found', 'libraries.noFoldersFound' => 'No folders found',
'libraries.folders' => 'folders', 'libraries.folders' => 'folders',
'libraries.tabs.recommended' => 'Recommended', 'libraries.tabs.recommended' => 'Recommended',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Browse', 'libraries.tabs.browse' => 'Browse',
'libraries.tabs.collections' => 'Collections', 'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists', 'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Grouping', 'libraries.groupings.title' => 'Grouping',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'All', 'libraries.groupings.all' => 'All',
'libraries.groupings.movies' => 'Movies', 'libraries.groupings.movies' => 'Movies',
'libraries.groupings.shows' => 'TV Shows', 'libraries.groupings.shows' => 'TV Shows',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesEs implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Error al cargar la serie'; @override String get errorLoadingSeries => 'Error al cargar la serie';
@override String get errorLoadingSeason => 'Error al cargar la temporada'; @override String get errorLoadingSeason => 'Error al cargar la temporada';
@override String get musicNotSupported => 'La reproducción de música aún no está soportada'; @override String get musicNotSupported => 'La reproducción de música aún no está soportada';
@override String get noDescriptionAvailable => 'No hay descripción disponible';
@override String get noProfilesAvailable => 'No hay perfiles disponibles';
@override String get contactAdminForProfiles => 'Contacta con tu administrador de Plex para añadir perfiles';
@override String get unableToDetermineLibrarySection => 'No se puede determinar la sección de biblioteca para este elemento';
@override String get logsCleared => 'Logs borrados'; @override String get logsCleared => 'Logs borrados';
@override String get logsCopied => 'Logs copiados al portapapeles'; @override String get logsCopied => 'Logs copiados al portapapeles';
@override String get noLogsAvailable => 'No hay logs disponibles'; @override String get noLogsAvailable => 'No hay logs disponibles';
@@ -1799,6 +1803,10 @@ extension on TranslationsEs {
'messages.errorLoadingSeries' => 'Error al cargar la serie', 'messages.errorLoadingSeries' => 'Error al cargar la serie',
'messages.errorLoadingSeason' => 'Error al cargar la temporada', 'messages.errorLoadingSeason' => 'Error al cargar la temporada',
'messages.musicNotSupported' => 'La reproducción de música aún no está soportada', 'messages.musicNotSupported' => 'La reproducción de música aún no está soportada',
'messages.noDescriptionAvailable' => 'No hay descripción disponible',
'messages.noProfilesAvailable' => 'No hay perfiles disponibles',
'messages.contactAdminForProfiles' => 'Contacta con tu administrador de Plex para añadir perfiles',
'messages.unableToDetermineLibrarySection' => 'No se puede determinar la sección de biblioteca para este elemento',
'messages.logsCleared' => 'Logs borrados', 'messages.logsCleared' => 'Logs borrados',
'messages.logsCopied' => 'Logs copiados al portapapeles', 'messages.logsCopied' => 'Logs copiados al portapapeles',
'messages.noLogsAvailable' => 'No hay logs disponibles', 'messages.noLogsAvailable' => 'No hay logs disponibles',
@@ -1913,12 +1921,12 @@ extension on TranslationsEs {
'libraries.noFoldersFound' => 'No se encontraron carpetas', 'libraries.noFoldersFound' => 'No se encontraron carpetas',
'libraries.folders' => 'carpetas', 'libraries.folders' => 'carpetas',
'libraries.tabs.recommended' => 'Recomendado', 'libraries.tabs.recommended' => 'Recomendado',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Explorar', 'libraries.tabs.browse' => 'Explorar',
'libraries.tabs.collections' => 'Colecciones', 'libraries.tabs.collections' => 'Colecciones',
'libraries.tabs.playlists' => 'Listas', 'libraries.tabs.playlists' => 'Listas',
'libraries.groupings.title' => 'Agrupación', 'libraries.groupings.title' => 'Agrupación',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Todo', 'libraries.groupings.all' => 'Todo',
'libraries.groupings.movies' => 'Películas', 'libraries.groupings.movies' => 'Películas',
'libraries.groupings.shows' => 'Series', 'libraries.groupings.shows' => 'Series',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Erreur lors du chargement de la série'; @override String get errorLoadingSeries => 'Erreur lors du chargement de la série';
@override String get errorLoadingSeason => 'Erreur lors du chargement de la saison'; @override String get errorLoadingSeason => 'Erreur lors du chargement de la saison';
@override String get musicNotSupported => 'La lecture de musique n\'est pas encore prise en charge'; @override String get musicNotSupported => 'La lecture de musique n\'est pas encore prise en charge';
@override String get noDescriptionAvailable => 'Aucune description disponible';
@override String get noProfilesAvailable => 'Aucun profil disponible';
@override String get contactAdminForProfiles => 'Contactez votre administrateur Plex pour ajouter des profils';
@override String get unableToDetermineLibrarySection => 'Impossible de déterminer la section de la bibliothèque pour cet élément';
@override String get logsCleared => 'Logs effacés'; @override String get logsCleared => 'Logs effacés';
@override String get logsCopied => 'Logs copiés dans le presse-papier'; @override String get logsCopied => 'Logs copiés dans le presse-papier';
@override String get noLogsAvailable => 'Aucun log disponible'; @override String get noLogsAvailable => 'Aucun log disponible';
@@ -1799,6 +1803,10 @@ extension on TranslationsFr {
'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série', 'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série',
'messages.errorLoadingSeason' => 'Erreur lors du chargement de la saison', 'messages.errorLoadingSeason' => 'Erreur lors du chargement de la saison',
'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge', 'messages.musicNotSupported' => 'La lecture de musique n\'est pas encore prise en charge',
'messages.noDescriptionAvailable' => 'Aucune description disponible',
'messages.noProfilesAvailable' => 'Aucun profil disponible',
'messages.contactAdminForProfiles' => 'Contactez votre administrateur Plex pour ajouter des profils',
'messages.unableToDetermineLibrarySection' => 'Impossible de déterminer la section de la bibliothèque pour cet élément',
'messages.logsCleared' => 'Logs effacés', 'messages.logsCleared' => 'Logs effacés',
'messages.logsCopied' => 'Logs copiés dans le presse-papier', 'messages.logsCopied' => 'Logs copiés dans le presse-papier',
'messages.noLogsAvailable' => 'Aucun log disponible', 'messages.noLogsAvailable' => 'Aucun log disponible',
@@ -1913,12 +1921,12 @@ extension on TranslationsFr {
'libraries.noFoldersFound' => 'Aucun dossier trouvé', 'libraries.noFoldersFound' => 'Aucun dossier trouvé',
'libraries.folders' => 'dossiers', 'libraries.folders' => 'dossiers',
'libraries.tabs.recommended' => 'Recommandé', 'libraries.tabs.recommended' => 'Recommandé',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Parcourir', 'libraries.tabs.browse' => 'Parcourir',
'libraries.tabs.collections' => 'Collections', 'libraries.tabs.collections' => 'Collections',
'libraries.tabs.playlists' => 'Playlists', 'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Regroupement', 'libraries.groupings.title' => 'Regroupement',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Tous', 'libraries.groupings.all' => 'Tous',
'libraries.groupings.movies' => 'Films', 'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Show TV', 'libraries.groupings.shows' => 'Show TV',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Errore caricamento serie'; @override String get errorLoadingSeries => 'Errore caricamento serie';
@override String get errorLoadingSeason => 'Errore caricamento stagione'; @override String get errorLoadingSeason => 'Errore caricamento stagione';
@override String get musicNotSupported => 'La riproduzione musicale non è ancora supportata'; @override String get musicNotSupported => 'La riproduzione musicale non è ancora supportata';
@override String get noDescriptionAvailable => 'Nessuna descrizione disponibile';
@override String get noProfilesAvailable => 'Nessun profilo disponibile';
@override String get contactAdminForProfiles => 'Contatta il tuo amministratore Plex per aggiungere profili';
@override String get unableToDetermineLibrarySection => 'Impossibile determinare la sezione della libreria per questo elemento';
@override String get logsCleared => 'Log eliminati'; @override String get logsCleared => 'Log eliminati';
@override String get logsCopied => 'Log copiati negli appunti'; @override String get logsCopied => 'Log copiati negli appunti';
@override String get noLogsAvailable => 'Nessun log disponibile'; @override String get noLogsAvailable => 'Nessun log disponibile';
@@ -1799,6 +1803,10 @@ extension on TranslationsIt {
'messages.errorLoadingSeries' => 'Errore caricamento serie', 'messages.errorLoadingSeries' => 'Errore caricamento serie',
'messages.errorLoadingSeason' => 'Errore caricamento stagione', 'messages.errorLoadingSeason' => 'Errore caricamento stagione',
'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata', 'messages.musicNotSupported' => 'La riproduzione musicale non è ancora supportata',
'messages.noDescriptionAvailable' => 'Nessuna descrizione disponibile',
'messages.noProfilesAvailable' => 'Nessun profilo disponibile',
'messages.contactAdminForProfiles' => 'Contatta il tuo amministratore Plex per aggiungere profili',
'messages.unableToDetermineLibrarySection' => 'Impossibile determinare la sezione della libreria per questo elemento',
'messages.logsCleared' => 'Log eliminati', 'messages.logsCleared' => 'Log eliminati',
'messages.logsCopied' => 'Log copiati negli appunti', 'messages.logsCopied' => 'Log copiati negli appunti',
'messages.noLogsAvailable' => 'Nessun log disponibile', 'messages.noLogsAvailable' => 'Nessun log disponibile',
@@ -1913,12 +1921,12 @@ extension on TranslationsIt {
'libraries.noFoldersFound' => 'Nessuna cartella trovata', 'libraries.noFoldersFound' => 'Nessuna cartella trovata',
'libraries.folders' => 'cartelle', 'libraries.folders' => 'cartelle',
'libraries.tabs.recommended' => 'Consigliati', 'libraries.tabs.recommended' => 'Consigliati',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Esplora', 'libraries.tabs.browse' => 'Esplora',
'libraries.tabs.collections' => 'Raccolte', 'libraries.tabs.collections' => 'Raccolte',
'libraries.tabs.playlists' => 'Playlist', 'libraries.tabs.playlists' => 'Playlist',
'libraries.groupings.title' => 'Raggruppamento', 'libraries.groupings.title' => 'Raggruppamento',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Tutti', 'libraries.groupings.all' => 'Tutti',
'libraries.groupings.movies' => 'Film', 'libraries.groupings.movies' => 'Film',
'libraries.groupings.shows' => 'Serie TV', 'libraries.groupings.shows' => 'Serie TV',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesJa implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'シリーズの読み込みエラー'; @override String get errorLoadingSeries => 'シリーズの読み込みエラー';
@override String get errorLoadingSeason => 'シーズンの読み込みエラー'; @override String get errorLoadingSeason => 'シーズンの読み込みエラー';
@override String get musicNotSupported => '音楽の再生はまだサポートされていません'; @override String get musicNotSupported => '音楽の再生はまだサポートされていません';
@override String get noDescriptionAvailable => '説明はありません';
@override String get noProfilesAvailable => '利用可能なプロフィールがありません';
@override String get contactAdminForProfiles => 'プロフィールを追加するにはPlex管理者にお問い合わせください';
@override String get unableToDetermineLibrarySection => 'このアイテムのライブラリセクションを判別できません';
@override String get logsCleared => 'ログをクリアしました'; @override String get logsCleared => 'ログをクリアしました';
@override String get logsCopied => 'ログをクリップボードにコピーしました'; @override String get logsCopied => 'ログをクリップボードにコピーしました';
@override String get noLogsAvailable => 'ログがありません'; @override String get noLogsAvailable => 'ログがありません';
@@ -1799,6 +1803,10 @@ extension on TranslationsJa {
'messages.errorLoadingSeries' => 'シリーズの読み込みエラー', 'messages.errorLoadingSeries' => 'シリーズの読み込みエラー',
'messages.errorLoadingSeason' => 'シーズンの読み込みエラー', 'messages.errorLoadingSeason' => 'シーズンの読み込みエラー',
'messages.musicNotSupported' => '音楽の再生はまだサポートされていません', 'messages.musicNotSupported' => '音楽の再生はまだサポートされていません',
'messages.noDescriptionAvailable' => '説明はありません',
'messages.noProfilesAvailable' => '利用可能なプロフィールがありません',
'messages.contactAdminForProfiles' => 'プロフィールを追加するにはPlex管理者にお問い合わせください',
'messages.unableToDetermineLibrarySection' => 'このアイテムのライブラリセクションを判別できません',
'messages.logsCleared' => 'ログをクリアしました', 'messages.logsCleared' => 'ログをクリアしました',
'messages.logsCopied' => 'ログをクリップボードにコピーしました', 'messages.logsCopied' => 'ログをクリップボードにコピーしました',
'messages.noLogsAvailable' => 'ログがありません', 'messages.noLogsAvailable' => 'ログがありません',
@@ -1913,12 +1921,12 @@ extension on TranslationsJa {
'libraries.noFoldersFound' => 'フォルダが見つかりません', 'libraries.noFoldersFound' => 'フォルダが見つかりません',
'libraries.folders' => 'フォルダ', 'libraries.folders' => 'フォルダ',
'libraries.tabs.recommended' => 'おすすめ', 'libraries.tabs.recommended' => 'おすすめ',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'ブラウズ', 'libraries.tabs.browse' => 'ブラウズ',
'libraries.tabs.collections' => 'コレクション', 'libraries.tabs.collections' => 'コレクション',
'libraries.tabs.playlists' => 'プレイリスト', 'libraries.tabs.playlists' => 'プレイリスト',
'libraries.groupings.title' => 'グループ', 'libraries.groupings.title' => 'グループ',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'すべて', 'libraries.groupings.all' => 'すべて',
'libraries.groupings.movies' => '映画', 'libraries.groupings.movies' => '映画',
'libraries.groupings.shows' => 'テレビ番組', 'libraries.groupings.shows' => 'テレビ番組',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn {
@override String get errorLoadingSeries => '시리즈 로딩 중 오류'; @override String get errorLoadingSeries => '시리즈 로딩 중 오류';
@override String get errorLoadingSeason => '시즌 로딩 중 오류'; @override String get errorLoadingSeason => '시즌 로딩 중 오류';
@override String get musicNotSupported => '음악 재생 미지원'; @override String get musicNotSupported => '음악 재생 미지원';
@override String get noDescriptionAvailable => '설명이 없습니다';
@override String get noProfilesAvailable => '사용 가능한 프로필이 없습니다';
@override String get contactAdminForProfiles => '프로필을 추가하려면 Plex 관리자에게 문의하세요';
@override String get unableToDetermineLibrarySection => '이 항목의 라이브러리 섹션을 확인할 수 없습니다';
@override String get logsCleared => '로그가 삭제 되었습니다'; @override String get logsCleared => '로그가 삭제 되었습니다';
@override String get logsCopied => '로그가 클립보드에 복사 되었습니다'; @override String get logsCopied => '로그가 클립보드에 복사 되었습니다';
@override String get noLogsAvailable => '사용 가능한 로그가 없습니다'; @override String get noLogsAvailable => '사용 가능한 로그가 없습니다';
@@ -1799,6 +1803,10 @@ extension on TranslationsKo {
'messages.errorLoadingSeries' => '시리즈 로딩 중 오류', 'messages.errorLoadingSeries' => '시리즈 로딩 중 오류',
'messages.errorLoadingSeason' => '시즌 로딩 중 오류', 'messages.errorLoadingSeason' => '시즌 로딩 중 오류',
'messages.musicNotSupported' => '음악 재생 미지원', 'messages.musicNotSupported' => '음악 재생 미지원',
'messages.noDescriptionAvailable' => '설명이 없습니다',
'messages.noProfilesAvailable' => '사용 가능한 프로필이 없습니다',
'messages.contactAdminForProfiles' => '프로필을 추가하려면 Plex 관리자에게 문의하세요',
'messages.unableToDetermineLibrarySection' => '이 항목의 라이브러리 섹션을 확인할 수 없습니다',
'messages.logsCleared' => '로그가 삭제 되었습니다', 'messages.logsCleared' => '로그가 삭제 되었습니다',
'messages.logsCopied' => '로그가 클립보드에 복사 되었습니다', 'messages.logsCopied' => '로그가 클립보드에 복사 되었습니다',
'messages.noLogsAvailable' => '사용 가능한 로그가 없습니다', 'messages.noLogsAvailable' => '사용 가능한 로그가 없습니다',
@@ -1913,12 +1921,12 @@ extension on TranslationsKo {
'libraries.noFoldersFound' => '폴더를 찾을 수 없습니다', 'libraries.noFoldersFound' => '폴더를 찾을 수 없습니다',
'libraries.folders' => '폴더', 'libraries.folders' => '폴더',
'libraries.tabs.recommended' => '추천', 'libraries.tabs.recommended' => '추천',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => '찾아보기', 'libraries.tabs.browse' => '찾아보기',
'libraries.tabs.collections' => '컬렉션', 'libraries.tabs.collections' => '컬렉션',
'libraries.tabs.playlists' => '재생 목록', 'libraries.tabs.playlists' => '재생 목록',
'libraries.groupings.title' => '그룹', 'libraries.groupings.title' => '그룹',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => '전체', 'libraries.groupings.all' => '전체',
'libraries.groupings.movies' => '영화', 'libraries.groupings.movies' => '영화',
'libraries.groupings.shows' => 'TV 프로그램', 'libraries.groupings.shows' => 'TV 프로그램',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesNb implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Feil ved lasting av serie'; @override String get errorLoadingSeries => 'Feil ved lasting av serie';
@override String get errorLoadingSeason => 'Feil ved lasting av sesong'; @override String get errorLoadingSeason => 'Feil ved lasting av sesong';
@override String get musicNotSupported => 'Musikkavspilling støttes ikke ennå'; @override String get musicNotSupported => 'Musikkavspilling støttes ikke ennå';
@override String get noDescriptionAvailable => 'Ingen beskrivelse tilgjengelig';
@override String get noProfilesAvailable => 'Ingen profiler tilgjengelige';
@override String get contactAdminForProfiles => 'Kontakt Plex-administratoren for å legge til profiler';
@override String get unableToDetermineLibrarySection => 'Kan ikke fastslå bibliotekseksjonen for dette elementet';
@override String get logsCleared => 'Logger tømt'; @override String get logsCleared => 'Logger tømt';
@override String get logsCopied => 'Logger kopiert til utklippstavle'; @override String get logsCopied => 'Logger kopiert til utklippstavle';
@override String get noLogsAvailable => 'Ingen logger tilgjengelig'; @override String get noLogsAvailable => 'Ingen logger tilgjengelig';
@@ -1799,6 +1803,10 @@ extension on TranslationsNb {
'messages.errorLoadingSeries' => 'Feil ved lasting av serie', 'messages.errorLoadingSeries' => 'Feil ved lasting av serie',
'messages.errorLoadingSeason' => 'Feil ved lasting av sesong', 'messages.errorLoadingSeason' => 'Feil ved lasting av sesong',
'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå', 'messages.musicNotSupported' => 'Musikkavspilling støttes ikke ennå',
'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgjengelig',
'messages.noProfilesAvailable' => 'Ingen profiler tilgjengelige',
'messages.contactAdminForProfiles' => 'Kontakt Plex-administratoren for å legge til profiler',
'messages.unableToDetermineLibrarySection' => 'Kan ikke fastslå bibliotekseksjonen for dette elementet',
'messages.logsCleared' => 'Logger tømt', 'messages.logsCleared' => 'Logger tømt',
'messages.logsCopied' => 'Logger kopiert til utklippstavle', 'messages.logsCopied' => 'Logger kopiert til utklippstavle',
'messages.noLogsAvailable' => 'Ingen logger tilgjengelig', 'messages.noLogsAvailable' => 'Ingen logger tilgjengelig',
@@ -1913,12 +1921,12 @@ extension on TranslationsNb {
'libraries.noFoldersFound' => 'Ingen mapper funnet', 'libraries.noFoldersFound' => 'Ingen mapper funnet',
'libraries.folders' => 'mapper', 'libraries.folders' => 'mapper',
'libraries.tabs.recommended' => 'Anbefalt', 'libraries.tabs.recommended' => 'Anbefalt',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bla gjennom', 'libraries.tabs.browse' => 'Bla gjennom',
'libraries.tabs.collections' => 'Samlinger', 'libraries.tabs.collections' => 'Samlinger',
'libraries.tabs.playlists' => 'Spillelister', 'libraries.tabs.playlists' => 'Spillelister',
'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.title' => 'Gruppering',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Alle', 'libraries.groupings.all' => 'Alle',
'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'TV-serier', 'libraries.groupings.shows' => 'TV-serier',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Fout bij laden serie'; @override String get errorLoadingSeries => 'Fout bij laden serie';
@override String get errorLoadingSeason => 'Fout bij laden seizoen'; @override String get errorLoadingSeason => 'Fout bij laden seizoen';
@override String get musicNotSupported => 'Muziek afspelen wordt nog niet ondersteund'; @override String get musicNotSupported => 'Muziek afspelen wordt nog niet ondersteund';
@override String get noDescriptionAvailable => 'Geen beschrijving beschikbaar';
@override String get noProfilesAvailable => 'Geen profielen beschikbaar';
@override String get contactAdminForProfiles => 'Neem contact op met je Plex-beheerder om profielen toe te voegen';
@override String get unableToDetermineLibrarySection => 'Kan bibliotheeksectie voor dit item niet bepalen';
@override String get logsCleared => 'Logs gewist'; @override String get logsCleared => 'Logs gewist';
@override String get logsCopied => 'Logs gekopieerd naar klembord'; @override String get logsCopied => 'Logs gekopieerd naar klembord';
@override String get noLogsAvailable => 'Geen logs beschikbaar'; @override String get noLogsAvailable => 'Geen logs beschikbaar';
@@ -1799,6 +1803,10 @@ extension on TranslationsNl {
'messages.errorLoadingSeries' => 'Fout bij laden serie', 'messages.errorLoadingSeries' => 'Fout bij laden serie',
'messages.errorLoadingSeason' => 'Fout bij laden seizoen', 'messages.errorLoadingSeason' => 'Fout bij laden seizoen',
'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund', 'messages.musicNotSupported' => 'Muziek afspelen wordt nog niet ondersteund',
'messages.noDescriptionAvailable' => 'Geen beschrijving beschikbaar',
'messages.noProfilesAvailable' => 'Geen profielen beschikbaar',
'messages.contactAdminForProfiles' => 'Neem contact op met je Plex-beheerder om profielen toe te voegen',
'messages.unableToDetermineLibrarySection' => 'Kan bibliotheeksectie voor dit item niet bepalen',
'messages.logsCleared' => 'Logs gewist', 'messages.logsCleared' => 'Logs gewist',
'messages.logsCopied' => 'Logs gekopieerd naar klembord', 'messages.logsCopied' => 'Logs gekopieerd naar klembord',
'messages.noLogsAvailable' => 'Geen logs beschikbaar', 'messages.noLogsAvailable' => 'Geen logs beschikbaar',
@@ -1913,12 +1921,12 @@ extension on TranslationsNl {
'libraries.noFoldersFound' => 'Geen mappen gevonden', 'libraries.noFoldersFound' => 'Geen mappen gevonden',
'libraries.folders' => 'mappen', 'libraries.folders' => 'mappen',
'libraries.tabs.recommended' => 'Aanbevolen', 'libraries.tabs.recommended' => 'Aanbevolen',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bladeren', 'libraries.tabs.browse' => 'Bladeren',
'libraries.tabs.collections' => 'Collecties', 'libraries.tabs.collections' => 'Collecties',
'libraries.tabs.playlists' => 'Afspeellijsten', 'libraries.tabs.playlists' => 'Afspeellijsten',
'libraries.groupings.title' => 'Groepering', 'libraries.groupings.title' => 'Groepering',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Alles', 'libraries.groupings.all' => 'Alles',
'libraries.groupings.movies' => 'Films', 'libraries.groupings.movies' => 'Films',
'libraries.groupings.shows' => 'Series', 'libraries.groupings.shows' => 'Series',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesPl implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Błąd ładowania serialu'; @override String get errorLoadingSeries => 'Błąd ładowania serialu';
@override String get errorLoadingSeason => 'Błąd ładowania sezonu'; @override String get errorLoadingSeason => 'Błąd ładowania sezonu';
@override String get musicNotSupported => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane'; @override String get musicNotSupported => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane';
@override String get noDescriptionAvailable => 'Brak dostępnego opisu';
@override String get noProfilesAvailable => 'Brak dostępnych profili';
@override String get contactAdminForProfiles => 'Skontaktuj się z administratorem Plex, aby dodać profile';
@override String get unableToDetermineLibrarySection => 'Nie można określić sekcji biblioteki dla tego elementu';
@override String get logsCleared => 'Logi wyczyszczone'; @override String get logsCleared => 'Logi wyczyszczone';
@override String get logsCopied => 'Logi skopiowane do schowka'; @override String get logsCopied => 'Logi skopiowane do schowka';
@override String get noLogsAvailable => 'Brak dostępnych logów'; @override String get noLogsAvailable => 'Brak dostępnych logów';
@@ -1799,6 +1803,10 @@ extension on TranslationsPl {
'messages.errorLoadingSeries' => 'Błąd ładowania serialu', 'messages.errorLoadingSeries' => 'Błąd ładowania serialu',
'messages.errorLoadingSeason' => 'Błąd ładowania sezonu', 'messages.errorLoadingSeason' => 'Błąd ładowania sezonu',
'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane', 'messages.musicNotSupported' => 'Odtwarzanie muzyki nie jest jeszcze obsługiwane',
'messages.noDescriptionAvailable' => 'Brak dostępnego opisu',
'messages.noProfilesAvailable' => 'Brak dostępnych profili',
'messages.contactAdminForProfiles' => 'Skontaktuj się z administratorem Plex, aby dodać profile',
'messages.unableToDetermineLibrarySection' => 'Nie można określić sekcji biblioteki dla tego elementu',
'messages.logsCleared' => 'Logi wyczyszczone', 'messages.logsCleared' => 'Logi wyczyszczone',
'messages.logsCopied' => 'Logi skopiowane do schowka', 'messages.logsCopied' => 'Logi skopiowane do schowka',
'messages.noLogsAvailable' => 'Brak dostępnych logów', 'messages.noLogsAvailable' => 'Brak dostępnych logów',
@@ -1913,12 +1921,12 @@ extension on TranslationsPl {
'libraries.noFoldersFound' => 'Nie znaleziono folderów', 'libraries.noFoldersFound' => 'Nie znaleziono folderów',
'libraries.folders' => 'foldery', 'libraries.folders' => 'foldery',
'libraries.tabs.recommended' => 'Rekomendowane', 'libraries.tabs.recommended' => 'Rekomendowane',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Przeglądaj', 'libraries.tabs.browse' => 'Przeglądaj',
'libraries.tabs.collections' => 'Kolekcje', 'libraries.tabs.collections' => 'Kolekcje',
'libraries.tabs.playlists' => 'Playlisty', 'libraries.tabs.playlists' => 'Playlisty',
'libraries.groupings.title' => 'Grupowanie', 'libraries.groupings.title' => 'Grupowanie',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Wszystkie', 'libraries.groupings.all' => 'Wszystkie',
'libraries.groupings.movies' => 'Filmy', 'libraries.groupings.movies' => 'Filmy',
'libraries.groupings.shows' => 'Seriale TV', 'libraries.groupings.shows' => 'Seriale TV',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesPt implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Erro ao carregar série'; @override String get errorLoadingSeries => 'Erro ao carregar série';
@override String get errorLoadingSeason => 'Erro ao carregar temporada'; @override String get errorLoadingSeason => 'Erro ao carregar temporada';
@override String get musicNotSupported => 'Reprodução de música ainda não é suportada'; @override String get musicNotSupported => 'Reprodução de música ainda não é suportada';
@override String get noDescriptionAvailable => 'Nenhuma descrição disponível';
@override String get noProfilesAvailable => 'Nenhum perfil disponível';
@override String get contactAdminForProfiles => 'Contacte o seu administrador Plex para adicionar perfis';
@override String get unableToDetermineLibrarySection => 'Não é possível determinar a secção da biblioteca para este item';
@override String get logsCleared => 'Logs limpos'; @override String get logsCleared => 'Logs limpos';
@override String get logsCopied => 'Logs copiados para a área de transferência'; @override String get logsCopied => 'Logs copiados para a área de transferência';
@override String get noLogsAvailable => 'Nenhum log disponível'; @override String get noLogsAvailable => 'Nenhum log disponível';
@@ -1799,6 +1803,10 @@ extension on TranslationsPt {
'messages.errorLoadingSeries' => 'Erro ao carregar série', 'messages.errorLoadingSeries' => 'Erro ao carregar série',
'messages.errorLoadingSeason' => 'Erro ao carregar temporada', 'messages.errorLoadingSeason' => 'Erro ao carregar temporada',
'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada', 'messages.musicNotSupported' => 'Reprodução de música ainda não é suportada',
'messages.noDescriptionAvailable' => 'Nenhuma descrição disponível',
'messages.noProfilesAvailable' => 'Nenhum perfil disponível',
'messages.contactAdminForProfiles' => 'Contacte o seu administrador Plex para adicionar perfis',
'messages.unableToDetermineLibrarySection' => 'Não é possível determinar a secção da biblioteca para este item',
'messages.logsCleared' => 'Logs limpos', 'messages.logsCleared' => 'Logs limpos',
'messages.logsCopied' => 'Logs copiados para a área de transferência', 'messages.logsCopied' => 'Logs copiados para a área de transferência',
'messages.noLogsAvailable' => 'Nenhum log disponível', 'messages.noLogsAvailable' => 'Nenhum log disponível',
@@ -1913,12 +1921,12 @@ extension on TranslationsPt {
'libraries.noFoldersFound' => 'Nenhuma pasta encontrada', 'libraries.noFoldersFound' => 'Nenhuma pasta encontrada',
'libraries.folders' => 'pastas', 'libraries.folders' => 'pastas',
'libraries.tabs.recommended' => 'Recomendados', 'libraries.tabs.recommended' => 'Recomendados',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Navegar', 'libraries.tabs.browse' => 'Navegar',
'libraries.tabs.collections' => 'Coleções', 'libraries.tabs.collections' => 'Coleções',
'libraries.tabs.playlists' => 'Playlists', 'libraries.tabs.playlists' => 'Playlists',
'libraries.groupings.title' => 'Agrupamento', 'libraries.groupings.title' => 'Agrupamento',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Todos', 'libraries.groupings.all' => 'Todos',
'libraries.groupings.movies' => 'Filmes', 'libraries.groupings.movies' => 'Filmes',
'libraries.groupings.shows' => 'Séries de TV', 'libraries.groupings.shows' => 'Séries de TV',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesRu implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Ошибка загрузки сериала'; @override String get errorLoadingSeries => 'Ошибка загрузки сериала';
@override String get errorLoadingSeason => 'Ошибка загрузки сезона'; @override String get errorLoadingSeason => 'Ошибка загрузки сезона';
@override String get musicNotSupported => 'Воспроизведение музыки пока не поддерживается'; @override String get musicNotSupported => 'Воспроизведение музыки пока не поддерживается';
@override String get noDescriptionAvailable => 'Описание недоступно';
@override String get noProfilesAvailable => 'Профили недоступны';
@override String get contactAdminForProfiles => 'Обратитесь к администратору Plex, чтобы добавить профили';
@override String get unableToDetermineLibrarySection => 'Не удаётся определить раздел библиотеки для этого элемента';
@override String get logsCleared => 'Логи очищены'; @override String get logsCleared => 'Логи очищены';
@override String get logsCopied => 'Логи скопированы в буфер обмена'; @override String get logsCopied => 'Логи скопированы в буфер обмена';
@override String get noLogsAvailable => 'Логи отсутствуют'; @override String get noLogsAvailable => 'Логи отсутствуют';
@@ -1799,6 +1803,10 @@ extension on TranslationsRu {
'messages.errorLoadingSeries' => 'Ошибка загрузки сериала', 'messages.errorLoadingSeries' => 'Ошибка загрузки сериала',
'messages.errorLoadingSeason' => 'Ошибка загрузки сезона', 'messages.errorLoadingSeason' => 'Ошибка загрузки сезона',
'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается', 'messages.musicNotSupported' => 'Воспроизведение музыки пока не поддерживается',
'messages.noDescriptionAvailable' => 'Описание недоступно',
'messages.noProfilesAvailable' => 'Профили недоступны',
'messages.contactAdminForProfiles' => 'Обратитесь к администратору Plex, чтобы добавить профили',
'messages.unableToDetermineLibrarySection' => 'Не удаётся определить раздел библиотеки для этого элемента',
'messages.logsCleared' => 'Логи очищены', 'messages.logsCleared' => 'Логи очищены',
'messages.logsCopied' => 'Логи скопированы в буфер обмена', 'messages.logsCopied' => 'Логи скопированы в буфер обмена',
'messages.noLogsAvailable' => 'Логи отсутствуют', 'messages.noLogsAvailable' => 'Логи отсутствуют',
@@ -1913,12 +1921,12 @@ extension on TranslationsRu {
'libraries.noFoldersFound' => 'Папки не найдены', 'libraries.noFoldersFound' => 'Папки не найдены',
'libraries.folders' => 'папки', 'libraries.folders' => 'папки',
'libraries.tabs.recommended' => 'Рекомендуемые', 'libraries.tabs.recommended' => 'Рекомендуемые',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Обзор', 'libraries.tabs.browse' => 'Обзор',
'libraries.tabs.collections' => 'Коллекции', 'libraries.tabs.collections' => 'Коллекции',
'libraries.tabs.playlists' => 'Плейлисты', 'libraries.tabs.playlists' => 'Плейлисты',
'libraries.groupings.title' => 'Группировка', 'libraries.groupings.title' => 'Группировка',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Все', 'libraries.groupings.all' => 'Все',
'libraries.groupings.movies' => 'Фильмы', 'libraries.groupings.movies' => 'Фильмы',
'libraries.groupings.shows' => 'Сериалы', 'libraries.groupings.shows' => 'Сериалы',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn {
@override String get errorLoadingSeries => 'Fel vid laddning av serie'; @override String get errorLoadingSeries => 'Fel vid laddning av serie';
@override String get errorLoadingSeason => 'Fel vid laddning av säsong'; @override String get errorLoadingSeason => 'Fel vid laddning av säsong';
@override String get musicNotSupported => 'Musikuppspelning stöds inte ännu'; @override String get musicNotSupported => 'Musikuppspelning stöds inte ännu';
@override String get noDescriptionAvailable => 'Ingen beskrivning tillgänglig';
@override String get noProfilesAvailable => 'Inga profiler tillgängliga';
@override String get contactAdminForProfiles => 'Kontakta din Plex-administratör för att lägga till profiler';
@override String get unableToDetermineLibrarySection => 'Kan inte avgöra biblioteksavdelningen för detta objekt';
@override String get logsCleared => 'Loggar rensade'; @override String get logsCleared => 'Loggar rensade';
@override String get logsCopied => 'Loggar kopierade till urklipp'; @override String get logsCopied => 'Loggar kopierade till urklipp';
@override String get noLogsAvailable => 'Inga loggar tillgängliga'; @override String get noLogsAvailable => 'Inga loggar tillgängliga';
@@ -1799,6 +1803,10 @@ extension on TranslationsSv {
'messages.errorLoadingSeries' => 'Fel vid laddning av serie', 'messages.errorLoadingSeries' => 'Fel vid laddning av serie',
'messages.errorLoadingSeason' => 'Fel vid laddning av säsong', 'messages.errorLoadingSeason' => 'Fel vid laddning av säsong',
'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu', 'messages.musicNotSupported' => 'Musikuppspelning stöds inte ännu',
'messages.noDescriptionAvailable' => 'Ingen beskrivning tillgänglig',
'messages.noProfilesAvailable' => 'Inga profiler tillgängliga',
'messages.contactAdminForProfiles' => 'Kontakta din Plex-administratör för att lägga till profiler',
'messages.unableToDetermineLibrarySection' => 'Kan inte avgöra biblioteksavdelningen för detta objekt',
'messages.logsCleared' => 'Loggar rensade', 'messages.logsCleared' => 'Loggar rensade',
'messages.logsCopied' => 'Loggar kopierade till urklipp', 'messages.logsCopied' => 'Loggar kopierade till urklipp',
'messages.noLogsAvailable' => 'Inga loggar tillgängliga', 'messages.noLogsAvailable' => 'Inga loggar tillgängliga',
@@ -1913,12 +1921,12 @@ extension on TranslationsSv {
'libraries.noFoldersFound' => 'Inga mappar hittades', 'libraries.noFoldersFound' => 'Inga mappar hittades',
'libraries.folders' => 'mappar', 'libraries.folders' => 'mappar',
'libraries.tabs.recommended' => 'Rekommenderat', 'libraries.tabs.recommended' => 'Rekommenderat',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => 'Bläddra', 'libraries.tabs.browse' => 'Bläddra',
'libraries.tabs.collections' => 'Samlingar', 'libraries.tabs.collections' => 'Samlingar',
'libraries.tabs.playlists' => 'Spellistor', 'libraries.tabs.playlists' => 'Spellistor',
'libraries.groupings.title' => 'Gruppering', 'libraries.groupings.title' => 'Gruppering',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => 'Alla', 'libraries.groupings.all' => 'Alla',
'libraries.groupings.movies' => 'Filmer', 'libraries.groupings.movies' => 'Filmer',
'libraries.groupings.shows' => 'Serier', 'libraries.groupings.shows' => 'Serier',
+10 -2
View File
@@ -579,6 +579,10 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn {
@override String get errorLoadingSeries => '加载系列时出错'; @override String get errorLoadingSeries => '加载系列时出错';
@override String get errorLoadingSeason => '加载季时出错'; @override String get errorLoadingSeason => '加载季时出错';
@override String get musicNotSupported => '尚不支持播放音乐'; @override String get musicNotSupported => '尚不支持播放音乐';
@override String get noDescriptionAvailable => '暂无描述';
@override String get noProfilesAvailable => '没有可用的用户';
@override String get contactAdminForProfiles => '请联系您的 Plex 管理员添加用户';
@override String get unableToDetermineLibrarySection => '无法确定此项目的库分区';
@override String get logsCleared => '日志已清除'; @override String get logsCleared => '日志已清除';
@override String get logsCopied => '日志已复制到剪贴板'; @override String get logsCopied => '日志已复制到剪贴板';
@override String get noLogsAvailable => '没有可用日志'; @override String get noLogsAvailable => '没有可用日志';
@@ -1799,6 +1803,10 @@ extension on TranslationsZh {
'messages.errorLoadingSeries' => '加载系列时出错', 'messages.errorLoadingSeries' => '加载系列时出错',
'messages.errorLoadingSeason' => '加载季时出错', 'messages.errorLoadingSeason' => '加载季时出错',
'messages.musicNotSupported' => '尚不支持播放音乐', 'messages.musicNotSupported' => '尚不支持播放音乐',
'messages.noDescriptionAvailable' => '暂无描述',
'messages.noProfilesAvailable' => '没有可用的用户',
'messages.contactAdminForProfiles' => '请联系您的 Plex 管理员添加用户',
'messages.unableToDetermineLibrarySection' => '无法确定此项目的库分区',
'messages.logsCleared' => '日志已清除', 'messages.logsCleared' => '日志已清除',
'messages.logsCopied' => '日志已复制到剪贴板', 'messages.logsCopied' => '日志已复制到剪贴板',
'messages.noLogsAvailable' => '没有可用日志', 'messages.noLogsAvailable' => '没有可用日志',
@@ -1913,12 +1921,12 @@ extension on TranslationsZh {
'libraries.noFoldersFound' => '未找到文件夹', 'libraries.noFoldersFound' => '未找到文件夹',
'libraries.folders' => '文件夹', 'libraries.folders' => '文件夹',
'libraries.tabs.recommended' => '推荐', 'libraries.tabs.recommended' => '推荐',
_ => null,
} ?? switch (path) {
'libraries.tabs.browse' => '浏览', 'libraries.tabs.browse' => '浏览',
'libraries.tabs.collections' => '合集', 'libraries.tabs.collections' => '合集',
'libraries.tabs.playlists' => '播放列表', 'libraries.tabs.playlists' => '播放列表',
'libraries.groupings.title' => '分组', 'libraries.groupings.title' => '分组',
_ => null,
} ?? switch (path) {
'libraries.groupings.all' => '全部', 'libraries.groupings.all' => '全部',
'libraries.groupings.movies' => '电影', 'libraries.groupings.movies' => '电影',
'libraries.groupings.shows' => '剧集', 'libraries.groupings.shows' => '剧集',
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "Fel vid laddning av serie", "errorLoadingSeries": "Fel vid laddning av serie",
"errorLoadingSeason": "Fel vid laddning av säsong", "errorLoadingSeason": "Fel vid laddning av säsong",
"musicNotSupported": "Musikuppspelning stöds inte ännu", "musicNotSupported": "Musikuppspelning stöds inte ännu",
"noDescriptionAvailable": "Ingen beskrivning tillgänglig",
"noProfilesAvailable": "Inga profiler tillgängliga",
"contactAdminForProfiles": "Kontakta din Plex-administratör för att lägga till profiler",
"unableToDetermineLibrarySection": "Kan inte avgöra biblioteksavdelningen för detta objekt",
"logsCleared": "Loggar rensade", "logsCleared": "Loggar rensade",
"logsCopied": "Loggar kopierade till urklipp", "logsCopied": "Loggar kopierade till urklipp",
"noLogsAvailable": "Inga loggar tillgängliga", "noLogsAvailable": "Inga loggar tillgängliga",
+4
View File
@@ -426,6 +426,10 @@
"errorLoadingSeries": "加载系列时出错", "errorLoadingSeries": "加载系列时出错",
"errorLoadingSeason": "加载季时出错", "errorLoadingSeason": "加载季时出错",
"musicNotSupported": "尚不支持播放音乐", "musicNotSupported": "尚不支持播放音乐",
"noDescriptionAvailable": "暂无描述",
"noProfilesAvailable": "没有可用的用户",
"contactAdminForProfiles": "请联系您的 Plex 管理员添加用户",
"unableToDetermineLibrarySection": "无法确定此项目的库分区",
"logsCleared": "日志已清除", "logsCleared": "日志已清除",
"logsCopied": "日志已复制到剪贴板", "logsCopied": "日志已复制到剪贴板",
"noLogsAvailable": "没有可用日志", "noLogsAvailable": "没有可用日志",
+4
View File
@@ -638,6 +638,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_startConnectivitySyncTrigger(downloadProvider); _startConnectivitySyncTrigger(downloadProvider);
// Thread the offline flag into services so queue/resume paths can
// short-circuit instead of hitting the network and failing.
downloadProvider.setOfflineSource(offlineModeProvider);
_offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider); _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
return _offlineWatchSyncService; return _offlineWatchSyncService;
}, },
+1 -1
View File
@@ -14,7 +14,7 @@ import '../widgets/focusable_tab_chip.dart';
/// ///
/// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab. /// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab.
mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderStateMixin<T> { mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderStateMixin<T> {
late TabController tabController; late final TabController tabController;
/// When true, suppress auto-focus in tabs (used when navigating via tab bar). /// When true, suppress auto-focus in tabs (used when navigating via tab bar).
bool suppressAutoFocus = false; bool suppressAutoFocus = false;
@@ -10,62 +10,44 @@ RemoteDevice _$RemoteDeviceFromJson(Map<String, dynamic> json) => RemoteDevice(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
platform: json['platform'] as String, platform: json['platform'] as String,
connectedAt: json['connectedAt'] == null connectedAt: json['connectedAt'] == null ? null : DateTime.parse(json['connectedAt'] as String),
? null capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as bool)),
: DateTime.parse(json['connectedAt'] as String),
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as bool),
),
); );
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) => Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) => <String, dynamic>{
<String, dynamic>{ 'id': instance.id,
'id': instance.id, 'name': instance.name,
'name': instance.name, 'platform': instance.platform,
'platform': instance.platform, 'connectedAt': instance.connectedAt.toIso8601String(),
'connectedAt': instance.connectedAt.toIso8601String(), 'capabilities': instance.capabilities,
'capabilities': instance.capabilities,
};
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) =>
RemoteSession(
role: $enumDecode(
_$RemoteSessionRoleEnumMap,
json['role'],
unknownValue: RemoteSessionRole.remote,
),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
? null
: RemoteDevice.fromJson(
json['connectedDevice'] as Map<String, dynamic>,
),
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) =>
<String, dynamic>{
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
const _$RemoteSessionRoleEnumMap = {
RemoteSessionRole.host: 'host',
RemoteSessionRole.remote: 'remote',
}; };
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) => RemoteSession(
role: $enumDecode(_$RemoteSessionRoleEnumMap, json['role'], unknownValue: RemoteSessionRole.remote),
status:
$enumDecodeNullable(
_$RemoteSessionStatusEnumMap,
json['status'],
unknownValue: RemoteSessionStatus.disconnected,
) ??
RemoteSessionStatus.disconnected,
connectedDevice: json['connectedDevice'] == null
? null
: RemoteDevice.fromJson(json['connectedDevice'] as Map<String, dynamic>),
createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
);
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) => <String, dynamic>{
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
'connectedDevice': instance.connectedDevice,
'createdAt': instance.createdAt.toIso8601String(),
'errorMessage': instance.errorMessage,
};
const _$RemoteSessionRoleEnumMap = {RemoteSessionRole.host: 'host', RemoteSessionRole.remote: 'remote'};
const _$RemoteSessionStatusEnumMap = { const _$RemoteSessionStatusEnumMap = {
RemoteSessionStatus.disconnected: 'disconnected', RemoteSessionStatus.disconnected: 'disconnected',
RemoteSessionStatus.connecting: 'connecting', RemoteSessionStatus.connecting: 'connecting',
+12 -18
View File
@@ -6,21 +6,15 @@ part of 'play_queue_response.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
PlayQueueResponse( playQueueID: (json['playQueueID'] as num).toInt(),
playQueueID: (json['playQueueID'] as num).toInt(), playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?) playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
?.toInt(), playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?) playQueueShuffled: flexibleBool(json['playQueueShuffled']),
?.toInt(), playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueSelectedMetadataItemID: playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
json['playQueueSelectedMetadataItemID'] as String?, playQueueVersion: (json['playQueueVersion'] as num).toInt(),
playQueueShuffled: flexibleBool(json['playQueueShuffled']), size: (json['size'] as num?)?.toInt(),
playQueueSourceURI: json['playQueueSourceURI'] as String?, items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(), );
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
+12 -13
View File
@@ -19,16 +19,15 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
hidden: (json['hidden'] as num?)?.toInt(), hidden: (json['hidden'] as num?)?.toInt(),
); );
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
<String, dynamic>{ 'key': instance.key,
'key': instance.key, 'title': instance.title,
'title': instance.title, 'type': instance.type,
'type': instance.type, 'agent': instance.agent,
'agent': instance.agent, 'scanner': instance.scanner,
'scanner': instance.scanner, 'language': instance.language,
'language': instance.language, 'uuid': instance.uuid,
'uuid': instance.uuid, 'updatedAt': instance.updatedAt,
'updatedAt': instance.updatedAt, 'createdAt': instance.createdAt,
'createdAt': instance.createdAt, 'hidden': instance.hidden,
'hidden': instance.hidden, };
};
+56 -59
View File
@@ -42,9 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
leafCount: (json['leafCount'] as num?)?.toInt(), leafCount: (json['leafCount'] as num?)?.toInt(),
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(), viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
childCount: flexibleInt(json['childCount']), childCount: flexibleInt(json['childCount']),
role: (json['Role'] as List<dynamic>?) role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
mediaVersions: (json['Media'] as List<dynamic>?) mediaVersions: (json['Media'] as List<dynamic>?)
?.map((e) => PlexMediaVersion.fromJson(e as Map<String, dynamic>)) ?.map((e) => PlexMediaVersion.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@@ -76,59 +74,58 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
backgroundSquare: json['backgroundSquare'] as String?, backgroundSquare: json['backgroundSquare'] as String?,
); );
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
<String, dynamic>{ 'ratingKey': instance.ratingKey,
'ratingKey': instance.ratingKey, 'key': instance.key,
'key': instance.key, 'guid': instance.guid,
'guid': instance.guid, 'studio': instance.studio,
'studio': instance.studio, 'type': instance.type,
'type': instance.type, 'title': instance.title,
'title': instance.title, 'titleSort': instance.titleSort,
'titleSort': instance.titleSort, 'contentRating': instance.contentRating,
'contentRating': instance.contentRating, 'summary': instance.summary,
'summary': instance.summary, 'rating': instance.rating,
'rating': instance.rating, 'audienceRating': instance.audienceRating,
'audienceRating': instance.audienceRating, 'userRating': instance.userRating,
'userRating': instance.userRating, 'year': instance.year,
'year': instance.year, 'originallyAvailableAt': instance.originallyAvailableAt,
'originallyAvailableAt': instance.originallyAvailableAt, 'thumb': instance.thumb,
'thumb': instance.thumb, 'art': instance.art,
'art': instance.art, 'duration': instance.duration,
'duration': instance.duration, 'addedAt': instance.addedAt,
'addedAt': instance.addedAt, 'updatedAt': instance.updatedAt,
'updatedAt': instance.updatedAt, 'lastViewedAt': instance.lastViewedAt,
'lastViewedAt': instance.lastViewedAt, 'grandparentTitle': instance.grandparentTitle,
'grandparentTitle': instance.grandparentTitle, 'grandparentThumb': instance.grandparentThumb,
'grandparentThumb': instance.grandparentThumb, 'grandparentArt': instance.grandparentArt,
'grandparentArt': instance.grandparentArt, 'grandparentRatingKey': instance.grandparentRatingKey,
'grandparentRatingKey': instance.grandparentRatingKey, 'parentTitle': instance.parentTitle,
'parentTitle': instance.parentTitle, 'parentThumb': instance.parentThumb,
'parentThumb': instance.parentThumb, 'parentRatingKey': instance.parentRatingKey,
'parentRatingKey': instance.parentRatingKey, 'parentIndex': instance.parentIndex,
'parentIndex': instance.parentIndex, 'index': instance.index,
'index': instance.index, 'grandparentTheme': instance.grandparentTheme,
'grandparentTheme': instance.grandparentTheme, 'viewOffset': instance.viewOffset,
'viewOffset': instance.viewOffset, 'viewCount': instance.viewCount,
'viewCount': instance.viewCount, 'leafCount': instance.leafCount,
'leafCount': instance.leafCount, 'viewedLeafCount': instance.viewedLeafCount,
'viewedLeafCount': instance.viewedLeafCount, 'childCount': instance.childCount,
'childCount': instance.childCount, 'Role': instance.role,
'Role': instance.role, 'audioLanguage': instance.audioLanguage,
'audioLanguage': instance.audioLanguage, 'subtitleLanguage': instance.subtitleLanguage,
'subtitleLanguage': instance.subtitleLanguage, 'subtitleMode': instance.subtitleMode,
'subtitleMode': instance.subtitleMode, 'playlistItemID': instance.playlistItemID,
'playlistItemID': instance.playlistItemID, 'playQueueItemID': instance.playQueueItemID,
'playQueueItemID': instance.playQueueItemID, 'librarySectionID': instance.librarySectionID,
'librarySectionID': instance.librarySectionID, 'librarySectionTitle': instance.librarySectionTitle,
'librarySectionTitle': instance.librarySectionTitle, 'ratingImage': instance.ratingImage,
'ratingImage': instance.ratingImage, 'audienceRatingImage': instance.audienceRatingImage,
'audienceRatingImage': instance.audienceRatingImage, 'tagline': instance.tagline,
'tagline': instance.tagline, 'originalTitle': instance.originalTitle,
'originalTitle': instance.originalTitle, 'editionTitle': instance.editionTitle,
'editionTitle': instance.editionTitle, 'subtype': instance.subtype,
'subtype': instance.subtype, 'extraType': instance.extraType,
'extraType': instance.extraType, 'primaryExtraKey': instance.primaryExtraKey,
'primaryExtraKey': instance.primaryExtraKey, 'clearLogo': instance.clearLogo,
'clearLogo': instance.clearLogo, 'backgroundSquare': instance.backgroundSquare,
'backgroundSquare': instance.backgroundSquare, };
};
+19 -20
View File
@@ -26,23 +26,22 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
thumb: json['thumb'] as String?, thumb: json['thumb'] as String?,
); );
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
<String, dynamic>{ 'ratingKey': instance.ratingKey,
'ratingKey': instance.ratingKey, 'key': instance.key,
'key': instance.key, 'type': instance.type,
'type': instance.type, 'title': instance.title,
'title': instance.title, 'summary': instance.summary,
'summary': instance.summary, 'smart': instance.smart,
'smart': instance.smart, 'playlistType': instance.playlistType,
'playlistType': instance.playlistType, 'duration': instance.duration,
'duration': instance.duration, 'leafCount': instance.leafCount,
'leafCount': instance.leafCount, 'composite': instance.composite,
'composite': instance.composite, 'addedAt': instance.addedAt,
'addedAt': instance.addedAt, 'updatedAt': instance.updatedAt,
'updatedAt': instance.updatedAt, 'lastViewedAt': instance.lastViewedAt,
'lastViewedAt': instance.lastViewedAt, 'viewCount': instance.viewCount,
'viewCount': instance.viewCount, 'content': instance.content,
'content': instance.content, 'guid': instance.guid,
'guid': instance.guid, 'thumb': instance.thumb,
'thumb': instance.thumb, };
};
+1 -5
View File
@@ -288,11 +288,7 @@ class Media {
/// Optional start position for playback. /// Optional start position for playback.
final Duration? start; final Duration? start;
/// Optional pre-known video frame rate (from server metadata), used to drive const Media(this.uri, {this.headers, this.start});
/// display refresh-rate matching before the first frame renders.
final double? fps;
const Media(this.uri, {this.headers, this.start, this.fps});
@override @override
String toString() => 'Media($uri)'; String toString() => 'Media($uri)';
-5
View File
@@ -487,11 +487,6 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
trackController.add(_state.track); trackController.add(_state.track);
} }
/// Update the internal state.
void updateState(PlayerState Function(PlayerState) update) {
_state = update(_state);
}
@protected @protected
void clearTracks() { void clearTracks() {
const empty = Tracks(); const empty = Tracks();
+88 -97
View File
@@ -10,11 +10,13 @@ import '../database/app_database.dart';
import '../services/download_manager_service.dart'; import '../services/download_manager_service.dart';
import '../services/download_storage_service.dart'; import '../services/download_storage_service.dart';
import '../services/multi_server_manager.dart'; import '../services/multi_server_manager.dart';
import '../services/offline_mode_source.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../services/plex_api_cache.dart'; import '../services/plex_api_cache.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../services/sync_rule_executor.dart'; import '../services/sync_rule_executor.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/episode_collection.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
/// Filter mode for batch downloads (shows/seasons). /// Filter mode for batch downloads (shows/seasons).
@@ -67,6 +69,8 @@ class DownloadProvider extends ChangeNotifier {
// Persistent sync rules: globalKey -> SyncRuleItem // Persistent sync rules: globalKey -> SyncRuleItem
final Map<String, SyncRuleItem> _syncRules = {}; final Map<String, SyncRuleItem> _syncRules = {};
OfflineModeSource? _offlineSource;
DownloadProvider({required DownloadManagerService downloadManager, required AppDatabase database}) DownloadProvider({required DownloadManagerService downloadManager, required AppDatabase database})
: _downloadManager = downloadManager, : _downloadManager = downloadManager,
_database = database, _database = database,
@@ -81,6 +85,15 @@ class DownloadProvider extends ChangeNotifier {
_initFuture = _loadPersistedDownloads(); _initFuture = _loadPersistedDownloads();
} }
/// Inject the offline-mode source so queueing paths can short-circuit when
/// the device has no Plex connectivity. Propagates to the download manager
/// and the sync-rule executor so background paths see the same flag.
void setOfflineSource(OfflineModeSource? source) {
_offlineSource = source;
_downloadManager.setOfflineSource(source);
_syncRuleExecutor.setOfflineSource(source);
}
/// Ensures persisted downloads have been loaded from disk. /// Ensures persisted downloads have been loaded from disk.
Future<void> ensureInitialized() => _initFuture; Future<void> ensureInitialized() => _initFuture;
@@ -694,14 +707,22 @@ class DownloadProvider extends ChangeNotifier {
// Hub items may have summary but the cache at /library/metadata/$ratingKey // Hub items may have summary but the cache at /library/metadata/$ratingKey
// won't have the full API response (with Media/Part data needed for video URL) // won't have the full API response (with Media/Part data needed for video URL)
// unless getMetadataWithImages has been called. // unless getMetadataWithImages has been called.
//
// Skip the fetch when offline — it would just fail. The partial metadata
// from whatever hub/grid invoked the queue is good enough to enqueue; the
// actual video URL resolves later when we're back online.
PlexMetadata metadataToStore = metadata; PlexMetadata metadataToStore = metadata;
try { if (_offlineSource?.isOffline ?? false) {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey); appLogger.d('Offline — using partial metadata for ${metadata.ratingKey}');
if (fullMetadata != null) { } else {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName); try {
final fullMetadata = await client.getMetadataWithImages(metadata.ratingKey);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(serverId: metadata.serverId, serverName: metadata.serverName);
}
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
} }
} catch (e) {
appLogger.w('Failed to fetch full metadata for ${metadata.ratingKey}, using partial', error: e);
} }
// Smart version matching for series/season downloads // Smart version matching for series/season downloads
@@ -796,29 +817,15 @@ class DownloadProvider extends ChangeNotifier {
DownloadFilter filter = DownloadFilter.all, DownloadFilter filter = DownloadFilter.all,
int? maxCount, int? maxCount,
}) async { }) async {
int count = 0;
final seasons = await client.getChildren(show.ratingKey);
await _storeLeafCount(show.globalKey, show); await _storeLeafCount(show.globalKey, show);
return _expandAndQueue(
int? remaining = maxCount; container: show,
for (final season in seasons) { client: client,
if (season.type == 'season') { versionConfig: versionConfig,
if (remaining != null && remaining <= 0) break; filter: filter,
final seasonWithServer = _ensureServerId(season, show.serverId); maxCount: maxCount,
final queued = await _queueSeasonDownload( skipExisting: false,
seasonWithServer, );
client,
versionConfig: versionConfig,
filter: filter,
maxCount: remaining,
);
count += queued;
if (remaining != null) remaining -= queued;
}
}
return count;
} }
/// Queue all episodes from a season for download /// Queue all episodes from a season for download
@@ -829,97 +836,81 @@ class DownloadProvider extends ChangeNotifier {
DownloadFilter filter = DownloadFilter.all, DownloadFilter filter = DownloadFilter.all,
int? maxCount, int? maxCount,
}) async { }) async {
int count = 0;
final episodes = await client.getChildren(season.ratingKey);
await _storeLeafCount(season.globalKey, season); await _storeLeafCount(season.globalKey, season);
return _expandAndQueue(
for (final episode in episodes) { container: season,
if (episode.type == 'episode') { client: client,
if (filter == DownloadFilter.unwatched && episode.isWatched && !episode.hasActiveProgress) continue; versionConfig: versionConfig,
if (maxCount != null && count >= maxCount) break; filter: filter,
maxCount: maxCount,
final episodeWithServer = _ensureServerId(episode, season.serverId); skipExisting: false,
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig); );
if (queued) count++;
}
}
return count;
} }
/// Queue only the missing (not downloaded) episodes for a show/season /// Queue only the missing (not downloaded) episodes for a show/season.
/// Used for resuming partial downloads /// Used for resuming partial downloads. Returns the number of episodes queued.
/// Returns the number of episodes queued
Future<int> queueMissingEpisodes( Future<int> queueMissingEpisodes(
PlexMetadata metadata, PlexMetadata metadata,
PlexClient client, { PlexClient client, {
DownloadVersionConfig? versionConfig, DownloadVersionConfig? versionConfig,
}) async { }) async {
final mt = metadata.mediaType; final mt = metadata.mediaType;
if (mt != PlexMediaType.show && mt != PlexMediaType.season) {
if (mt == PlexMediaType.show) {
return await _queueMissingShowEpisodes(metadata, client, versionConfig: versionConfig);
} else if (mt == PlexMediaType.season) {
return await _queueMissingSeasonEpisodes(metadata, client, versionConfig: versionConfig);
} else {
throw Exception('queueMissingEpisodes only supports shows/seasons'); throw Exception('queueMissingEpisodes only supports shows/seasons');
} }
final queued = await _expandAndQueue(
container: metadata,
client: client,
versionConfig: versionConfig,
filter: DownloadFilter.all,
maxCount: null,
skipExisting: true,
);
if (mt == PlexMediaType.show) {
appLogger.i('Queued $queued missing episodes for show ${metadata.title}');
}
return queued;
} }
/// Queue missing episodes for a show /// Shared expansion: fetch all episodes under [container] (show or season),
Future<int> _queueMissingShowEpisodes( /// apply [filter] and optional [maxCount], optionally skip items already
PlexMetadata show, /// queued/downloading/completed ([skipExisting]), and queue each one.
PlexClient client, { Future<int> _expandAndQueue({
DownloadVersionConfig? versionConfig, required PlexMetadata container,
required PlexClient client,
required DownloadVersionConfig? versionConfig,
required DownloadFilter filter,
required int? maxCount,
required bool skipExisting,
}) async { }) async {
int queuedCount = 0; final unwatchedOnly = filter == DownloadFilter.unwatched;
final episodes = <PlexMetadata>[];
final seasons = await client.getChildren(show.ratingKey); if (container.mediaType == PlexMediaType.show) {
await collectEpisodesForShow(client, container.ratingKey, unwatchedOnly: unwatchedOnly, out: episodes);
for (final season in seasons) { } else {
if (season.type == 'season') { await collectEpisodesForSeason(client, container.ratingKey, unwatchedOnly: unwatchedOnly, out: episodes);
final seasonWithServer = _ensureServerId(season, show.serverId);
queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client, versionConfig: versionConfig);
}
} }
appLogger.i('Queued $queuedCount missing episodes for show ${show.title}'); int count = 0;
return queuedCount;
}
/// Queue missing episodes for a season
Future<int> _queueMissingSeasonEpisodes(
PlexMetadata season,
PlexClient client, {
DownloadVersionConfig? versionConfig,
}) async {
int queuedCount = 0;
final episodes = await client.getChildren(season.ratingKey);
for (final episode in episodes) { for (final episode in episodes) {
if (episode.type == 'episode') { if (maxCount != null && count >= maxCount) break;
final episodeWithServer = _ensureServerId(episode, season.serverId);
final episodeGlobalKey = episodeWithServer.globalKey; final episodeWithServer = _ensureServerId(episode, container.serverId);
// Only queue if NOT already downloaded or in progress if (skipExisting) {
final progress = _downloads[episodeGlobalKey]; final progress = _downloads[episodeWithServer.globalKey];
if (progress == null || if (progress != null &&
(progress.status != DownloadStatus.completed && (progress.status == DownloadStatus.completed ||
progress.status != DownloadStatus.downloading && progress.status == DownloadStatus.downloading ||
progress.status != DownloadStatus.queued)) { progress.status == DownloadStatus.queued)) {
final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig); continue;
if (queued) {
queuedCount++;
appLogger.d('Queued missing episode: ${episode.title} ($episodeGlobalKey)');
}
} }
} }
}
return queuedCount; final queued = await _queueSingleDownload(episodeWithServer, client, versionConfig: versionConfig);
if (queued) count++;
}
return count;
} }
/// Pause a download (works for both downloading and queued items) /// Pause a download (works for both downloading and queued items)
+1 -1
View File
@@ -313,7 +313,7 @@ class _AuthScreenState extends State<AuthScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Use two-column layout on desktop, single column on mobile // Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.of(context).size.width > 700; final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Scaffold( return Scaffold(
body: Center( body: Center(
+3 -3
View File
@@ -1197,7 +1197,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
Widget _buildHeroSection() { Widget _buildHeroSection() {
final statusBarHeight = MediaQuery.of(context).padding.top; final statusBarHeight = MediaQuery.of(context).padding.top;
final useSideNav = PlatformDetector.shouldUseSideNavigation(context); final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
final heroHeight = useSideNav ? MediaQuery.of(context).size.height * 0.75 : 500 + statusBarHeight; final heroHeight = useSideNav ? MediaQuery.sizeOf(context).height * 0.75 : 500 + statusBarHeight;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Focus( child: Focus(
focusNode: _heroFocusNode, focusNode: _heroFocusNode,
@@ -1317,7 +1317,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final heroClient = _getClientForItem(heroItem); final heroClient = _getClientForItem(heroItem);
final isEpisode = heroItem.isEpisode; final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle; final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.sizeOf(context).width;
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth); final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
// Determine content type label for chip // Determine content type label for chip
@@ -1566,7 +1566,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
TextSpan( TextSpan(
text: heroItem.summary?.isNotEmpty == true text: heroItem.summary?.isNotEmpty == true
? heroItem.summary! ? heroItem.summary!
: 'No description available', : t.messages.noDescriptionAvailable,
), ),
], ],
), ),
@@ -73,9 +73,10 @@ class AdaptiveMediaGrid<T> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<SettingsProvider>( return Selector<SettingsProvider, (ViewMode, int)>(
builder: (context, settingsProvider, child) { selector: (_, p) => (p.viewMode, p.libraryDensity),
return _buildItemsView(context, settingsProvider.viewMode, settingsProvider.libraryDensity); builder: (context, slice, _) {
return _buildItemsView(context, slice.$1, slice.$2);
}, },
); );
} }
@@ -1228,7 +1228,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
/// Compute initial fetch size based on viewport dimensions. /// Compute initial fetch size based on viewport dimensions.
int _calculateInitialFetchSize() { int _calculateInitialFetchSize() {
try { try {
final screenSize = MediaQuery.of(context).size; final screenSize = MediaQuery.sizeOf(context);
final settingsProvider = context.read<SettingsProvider>(); final settingsProvider = context.read<SettingsProvider>();
final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); final maxExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity);
final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing; final crossAxisSpacing = GridLayoutConstants.crossAxisSpacing;
+1 -1
View File
@@ -244,7 +244,7 @@ class GuideTabState extends State<GuideTab> {
final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth; final offset = (minutesSinceStart / _minutesPerSlot) * _slotWidth;
if (_gridHorizontalController.hasClients) { if (_gridHorizontalController.hasClients) {
_gridHorizontalController.jumpTo( _gridHorizontalController.jumpTo(
(offset - MediaQuery.of(context).size.width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent), (offset - MediaQuery.sizeOf(context).width / 3).clamp(0, _gridHorizontalController.position.maxScrollExtent),
); );
} }
}); });
+2 -2
View File
@@ -1719,7 +1719,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// Uses the shared grid size calculator for consistency with library grids. /// Uses the shared grid size calculator for consistency with library grids.
double _getResponsiveCardWidth() { double _getResponsiveCardWidth() {
final density = context.read<SettingsProvider>().libraryDensity; final density = context.read<SettingsProvider>().libraryDensity;
final availableWidth = MediaQuery.of(context).size.width; final availableWidth = MediaQuery.sizeOf(context).width;
return GridSizeCalculator.getCellWidth(availableWidth, context, density); return GridSizeCalculator.getCellWidth(availableWidth, context, density);
} }
@@ -2424,7 +2424,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} }
// Determine header height based on screen size // Determine header height based on screen size
final size = MediaQuery.of(context).size; final size = MediaQuery.sizeOf(context);
final headerHeight = size.height * 0.6; final headerHeight = size.height * 0.6;
final content = OverlaySheetHost( final content = OverlaySheetHost(
@@ -77,10 +77,10 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> {
), ),
) )
else if (users.isEmpty) else if (users.isEmpty)
const SliverFillRemaining( SliverFillRemaining(
child: EmptyStateWidget( child: EmptyStateWidget(
message: 'No profiles available', message: t.messages.noProfilesAvailable,
subtitle: 'Contact your Plex administrator to add profiles', subtitle: t.messages.contactAdminForProfiles,
icon: Symbols.person_off_rounded, icon: Symbols.person_off_rounded,
), ),
) )
+7 -11
View File
@@ -12,7 +12,6 @@ class UserAvatarWidget extends StatelessWidget {
final double size; final double size;
final bool showIndicators; final bool showIndicators;
final bool useTextLabels; final bool useTextLabels;
final VoidCallback? onTap;
const UserAvatarWidget({ const UserAvatarWidget({
super.key, super.key,
@@ -20,7 +19,6 @@ class UserAvatarWidget extends StatelessWidget {
this.size = 40, this.size = 40,
this.showIndicators = true, this.showIndicators = true,
this.useTextLabels = false, this.useTextLabels = false,
this.onTap,
}); });
Widget _buildPlaceholderAvatar(ThemeData theme) { Widget _buildPlaceholderAvatar(ThemeData theme) {
@@ -196,15 +194,13 @@ class UserAvatarWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return useTextLabels if (useTextLabels) {
? GestureDetector( return Column(
onTap: onTap, mainAxisSize: MainAxisSize.min,
child: Column( children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)],
mainAxisSize: MainAxisSize.min, );
children: [_buildAvatar(context, theme), ..._buildTextLabels(context, theme)], }
), return _buildAvatar(context, theme);
)
: GestureDetector(onTap: onTap, child: _buildAvatar(context, theme));
} }
} }
@@ -142,8 +142,9 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
} }
Widget _buildDensitySelector() { Widget _buildDensitySelector() {
return Consumer<SettingsProvider>( return Selector<SettingsProvider, int>(
builder: (context, settingsProvider, child) { selector: (_, p) => p.libraryDensity,
builder: (context, density, _) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column( child: Column(
@@ -156,11 +157,11 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)), Text(t.settings.compact, style: const TextStyle(fontSize: 12, color: Colors.grey)),
Expanded( Expanded(
child: FocusableSlider( child: FocusableSlider(
value: settingsProvider.libraryDensity.toDouble(), value: density.toDouble(),
min: 1, min: 1,
max: 5, max: 5,
divisions: 4, divisions: 4,
onChanged: (value) => settingsProvider.setLibraryDensity(value.round()), onChanged: (value) => context.read<SettingsProvider>().setLibraryDensity(value.round()),
), ),
), ),
Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)), Text(t.settings.comfortable, style: const TextStyle(fontSize: 12, color: Colors.grey)),
@@ -174,8 +175,9 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
} }
Widget _buildViewModeSelector() { Widget _buildViewModeSelector() {
return Consumer<SettingsProvider>( return Selector<SettingsProvider, settings.ViewMode>(
builder: (context, settingsProvider, child) { selector: (_, p) => p.viewMode,
builder: (context, viewMode, _) {
return SegmentedSetting<settings.ViewMode>( return SegmentedSetting<settings.ViewMode>(
icon: Symbols.view_list_rounded, icon: Symbols.view_list_rounded,
title: t.settings.viewMode, title: t.settings.viewMode,
@@ -183,16 +185,17 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
ButtonSegment(value: settings.ViewMode.grid, label: Text(t.settings.gridView)), ButtonSegment(value: settings.ViewMode.grid, label: Text(t.settings.gridView)),
ButtonSegment(value: settings.ViewMode.list, label: Text(t.settings.listView)), ButtonSegment(value: settings.ViewMode.list, label: Text(t.settings.listView)),
], ],
selected: settingsProvider.viewMode, selected: viewMode,
onChanged: (value) => settingsProvider.setViewMode(value), onChanged: (value) => context.read<SettingsProvider>().setViewMode(value),
); );
}, },
); );
} }
Widget _buildEpisodePosterModeSelector() { Widget _buildEpisodePosterModeSelector() {
return Consumer<SettingsProvider>( return Selector<SettingsProvider, settings.EpisodePosterMode>(
builder: (context, settingsProvider, child) { selector: (_, p) => p.episodePosterMode,
builder: (context, mode, _) {
return SegmentedSetting<settings.EpisodePosterMode>( return SegmentedSetting<settings.EpisodePosterMode>(
icon: Symbols.image_rounded, icon: Symbols.image_rounded,
title: t.settings.episodePosterMode, title: t.settings.episodePosterMode,
@@ -201,146 +204,112 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
ButtonSegment(value: settings.EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), ButtonSegment(value: settings.EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)),
ButtonSegment(value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), ButtonSegment(value: settings.EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
], ],
selected: settingsProvider.episodePosterMode, selected: mode,
onChanged: (value) => settingsProvider.setEpisodePosterMode(value), onChanged: (value) => context.read<SettingsProvider>().setEpisodePosterMode(value),
); );
}, },
); );
} }
/// Shared scaffolding for the bool toggles on this screen. Each toggle
/// watches one `SettingsProvider` field via `Selector`, so flipping one
/// switch doesn't rebuild the others.
Widget _buildBoolToggle({
required IconData icon,
required String title,
required String subtitle,
required bool Function(SettingsProvider) getter,
required Future<void> Function(SettingsProvider, bool) setter,
}) {
return Selector<SettingsProvider, bool>(
selector: (_, p) => getter(p),
builder: (context, value, _) => SwitchListTile(
secondary: AppIcon(icon, fill: 1),
title: Text(title),
subtitle: Text(subtitle),
value: value,
onChanged: (v) => setter(context.read<SettingsProvider>(), v),
),
);
}
// --- Home Screen section --- // --- Home Screen section ---
Widget _buildShowHeroSection() { Widget _buildShowHeroSection() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.featured_play_list_rounded,
builder: (context, settingsProvider, child) { title: t.settings.showHeroSection,
return SwitchListTile( subtitle: t.settings.showHeroSectionDescription,
secondary: const AppIcon(Symbols.featured_play_list_rounded, fill: 1), getter: (p) => p.showHeroSection,
title: Text(t.settings.showHeroSection), setter: (p, v) => p.setShowHeroSection(v),
subtitle: Text(t.settings.showHeroSectionDescription), );
value: settingsProvider.showHeroSection,
onChanged: (value) async {
await settingsProvider.setShowHeroSection(value);
},
);
},
);
}
Widget _buildUseGlobalHubs() { Widget _buildUseGlobalHubs() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.home_rounded,
builder: (context, settingsProvider, child) { title: t.settings.useGlobalHubs,
return SwitchListTile( subtitle: t.settings.useGlobalHubsDescription,
secondary: const AppIcon(Symbols.home_rounded, fill: 1), getter: (p) => p.useGlobalHubs,
title: Text(t.settings.useGlobalHubs), setter: (p, v) => p.setUseGlobalHubs(v),
subtitle: Text(t.settings.useGlobalHubsDescription), );
value: settingsProvider.useGlobalHubs,
onChanged: (value) async {
await settingsProvider.setUseGlobalHubs(value);
},
);
},
);
}
Widget _buildShowServerNameOnHubs() { Widget _buildShowServerNameOnHubs() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.dns_rounded,
builder: (context, settingsProvider, child) { title: t.settings.showServerNameOnHubs,
return SwitchListTile( subtitle: t.settings.showServerNameOnHubsDescription,
secondary: const AppIcon(Symbols.dns_rounded, fill: 1), getter: (p) => p.showServerNameOnHubs,
title: Text(t.settings.showServerNameOnHubs), setter: (p, v) => p.setShowServerNameOnHubs(v),
subtitle: Text(t.settings.showServerNameOnHubsDescription), );
value: settingsProvider.showServerNameOnHubs,
onChanged: (value) async {
await settingsProvider.setShowServerNameOnHubs(value);
},
);
},
);
}
// --- Navigation section --- // --- Navigation section ---
Widget _buildAlwaysKeepSidebarOpen() { Widget _buildAlwaysKeepSidebarOpen() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.dock_to_left_rounded,
builder: (context, settingsProvider, child) { title: t.settings.alwaysKeepSidebarOpen,
return SwitchListTile( subtitle: t.settings.alwaysKeepSidebarOpenDescription,
secondary: const AppIcon(Symbols.dock_to_left_rounded, fill: 1), getter: (p) => p.alwaysKeepSidebarOpen,
title: Text(t.settings.alwaysKeepSidebarOpen), setter: (p, v) => p.setAlwaysKeepSidebarOpen(v),
subtitle: Text(t.settings.alwaysKeepSidebarOpenDescription), );
value: settingsProvider.alwaysKeepSidebarOpen,
onChanged: (value) async {
await settingsProvider.setAlwaysKeepSidebarOpen(value);
},
);
},
);
}
Widget _buildShowNavBarLabels() { Widget _buildShowNavBarLabels() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.label_rounded,
builder: (context, settingsProvider, child) { title: t.settings.showNavBarLabels,
return SwitchListTile( subtitle: t.settings.showNavBarLabelsDescription,
secondary: const AppIcon(Symbols.label_rounded, fill: 1), getter: (p) => p.showNavBarLabels,
title: Text(t.settings.showNavBarLabels), setter: (p, v) => p.setShowNavBarLabels(v),
subtitle: Text(t.settings.showNavBarLabelsDescription), );
value: settingsProvider.showNavBarLabels,
onChanged: (value) async {
await settingsProvider.setShowNavBarLabels(value);
},
);
},
);
}
Widget _buildShowUnwatchedCount() { Widget _buildShowUnwatchedCount() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.counter_1_rounded,
builder: (context, settingsProvider, child) { title: t.settings.showUnwatchedCount,
return SwitchListTile( subtitle: t.settings.showUnwatchedCountDescription,
secondary: const AppIcon(Symbols.counter_1_rounded, fill: 1), getter: (p) => p.showUnwatchedCount,
title: Text(t.settings.showUnwatchedCount), setter: (p, v) => p.setShowUnwatchedCount(v),
subtitle: Text(t.settings.showUnwatchedCountDescription), );
value: settingsProvider.showUnwatchedCount,
onChanged: (value) async {
await settingsProvider.setShowUnwatchedCount(value);
},
);
},
);
}
// --- Content section --- // --- Content section ---
Widget _buildLiveTvDefaultFavorites() { Widget _buildLiveTvDefaultFavorites() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.star_rounded,
builder: (context, settingsProvider, child) { title: t.settings.liveTvDefaultFavorites,
return SwitchListTile( subtitle: t.settings.liveTvDefaultFavoritesDescription,
secondary: const AppIcon(Symbols.star_rounded, fill: 1), getter: (p) => p.liveTvDefaultFavorites,
title: Text(t.settings.liveTvDefaultFavorites), setter: (p, v) => p.setLiveTvDefaultFavorites(v),
subtitle: Text(t.settings.liveTvDefaultFavoritesDescription), );
value: settingsProvider.liveTvDefaultFavorites,
onChanged: (value) async {
await settingsProvider.setLiveTvDefaultFavorites(value);
},
);
},
);
}
Widget _buildHideSpoilers() { Widget _buildHideSpoilers() => _buildBoolToggle(
return Consumer<SettingsProvider>( icon: Symbols.visibility_off_rounded,
builder: (context, settingsProvider, child) { title: t.settings.hideSpoilers,
return SwitchListTile( subtitle: t.settings.hideSpoilersDescription,
secondary: const AppIcon(Symbols.visibility_off_rounded, fill: 1), getter: (p) => p.hideSpoilers,
title: Text(t.settings.hideSpoilers), setter: (p, v) => p.setHideSpoilers(v),
subtitle: Text(t.settings.hideSpoilersDescription), );
value: settingsProvider.hideSpoilers,
onChanged: (value) async { Widget _buildAutoHidePerformanceOverlay() => _buildBoolToggle(
await settingsProvider.setHideSpoilers(value); icon: Symbols.speed_rounded,
}, title: t.settings.autoHidePerformanceOverlay,
); subtitle: t.settings.autoHidePerformanceOverlayDescription,
}, getter: (p) => p.autoHidePerformanceOverlay,
); setter: (p, v) => p.setAutoHidePerformanceOverlay(v),
} );
Widget _buildRequireProfileSelection() { Widget _buildRequireProfileSelection() {
return Consumer<UserProfileProvider>( return Consumer<UserProfileProvider>(
@@ -388,22 +357,6 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
); );
} }
Widget _buildAutoHidePerformanceOverlay() {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return SwitchListTile(
secondary: const AppIcon(Symbols.speed_rounded, fill: 1),
title: Text(t.settings.autoHidePerformanceOverlay),
subtitle: Text(t.settings.autoHidePerformanceOverlayDescription),
value: settingsProvider.autoHidePerformanceOverlay,
onChanged: (value) async {
await settingsProvider.setAutoHidePerformanceOverlay(value);
},
);
},
);
}
// --- Helpers --- // --- Helpers ---
String _getLanguageDisplayName(AppLocale locale) { String _getLanguageDisplayName(AppLocale locale) {
+1 -1
View File
@@ -1449,7 +1449,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
// them in a single prepare() — no media reload needed for selection. // them in a single prepare() — no media reload needed for selection.
// MPV (all platforms including Android): external subs added after open via sub-add. // MPV (all platforms including Android): external subs added after open via sub-add.
await player!.open( await player!.open(
Media(result.videoUrl!, start: resumePosition, headers: plexHeaders, fps: preKnownFps), Media(result.videoUrl!, start: resumePosition, headers: plexHeaders),
play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs), play: !willAutoSwitch && (isExoPlayer || !hasExternalSubs),
externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null, externalSubtitles: isExoPlayer && hasExternalSubs ? result.externalSubtitles : null,
); );
+43 -9
View File
@@ -12,6 +12,7 @@ import 'saf_storage_service.dart';
import '../models/download_models.dart'; import '../models/download_models.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../models/plex_media_info.dart'; import '../models/plex_media_info.dart';
import '../services/offline_mode_source.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../services/download_storage_service.dart'; import '../services/download_storage_service.dart';
import '../services/plex_api_cache.dart'; import '../services/plex_api_cache.dart';
@@ -71,6 +72,8 @@ class DownloadManagerService {
PlexClient? Function(String serverId)? _clientResolver; PlexClient? Function(String serverId)? _clientResolver;
PlexClient? _fallbackClient; PlexClient? _fallbackClient;
OfflineModeSource? _offlineSource;
// background_downloader state // background_downloader state
bool _fileDownloaderInitialized = false; bool _fileDownloaderInitialized = false;
static const _downloadGroup = 'video_downloads'; static const _downloadGroup = 'video_downloads';
@@ -145,6 +148,14 @@ class DownloadManagerService {
_clientResolver = resolver; _clientResolver = resolver;
} }
/// Inject the offline-mode source. When `isOffline`, queue/resume paths skip
/// network work and defer until connectivity returns.
void setOfflineSource(OfflineModeSource? source) {
_offlineSource = source;
}
bool get _isOffline => _offlineSource?.isOffline ?? false;
/// Look up the correct client for [serverId]. /// Look up the correct client for [serverId].
/// Returns null if the server is offline — callers should skip/defer the work. /// Returns null if the server is offline — callers should skip/defer the work.
PlexClient? _getClient(String? serverId) { PlexClient? _getClient(String? serverId) {
@@ -285,6 +296,11 @@ class DownloadManagerService {
void resumeQueuedDownloads(PlexClient client) { void resumeQueuedDownloads(PlexClient client) {
_fallbackClient = client; _fallbackClient = client;
if (_isOffline) {
appLogger.d('Skipping resumeQueuedDownloads — offline');
return;
}
// Attempt deferred supplementary downloads for recovered items // Attempt deferred supplementary downloads for recovered items
_processPendingSupplementaryDownloads(client); _processPendingSupplementaryDownloads(client);
@@ -349,13 +365,24 @@ class DownloadManagerService {
} }
} }
/// Cancel any per-download timers (progress debounce + auto-retry) for [key].
/// Idempotent; safe to call from any terminal/pause path.
void _cancelDownloadTimers(String key) {
_progressDebounceTimers.remove(key)?.cancel();
_autoRetryTimers.remove(key)?.cancel();
}
/// Delete a file if it exists and log the deletion /// Delete a file if it exists and log the deletion
/// Returns true if file was deleted, false otherwise /// Returns true if file was deleted, false otherwise
Future<bool> _deleteFileIfExists(File file, String description) async { Future<bool> _deleteFileIfExists(File file, String description) async {
if (await file.exists()) { try {
await file.delete(); if (await file.exists()) {
appLogger.i('Deleted $description: ${file.path}'); await file.delete();
return true; appLogger.i('Deleted $description: ${file.path}');
return true;
}
} catch (e) {
appLogger.w('Failed to delete $description: ${file.path}', error: e);
} }
return false; return false;
} }
@@ -715,6 +742,7 @@ class DownloadManagerService {
/// Handle a system-initiated cancel — re-queue unless already completed. /// Handle a system-initiated cancel — re-queue unless already completed.
Future<void> _onDownloadCanceled(String globalKey) async { Future<void> _onDownloadCanceled(String globalKey) async {
_cancelDownloadTimers(globalKey);
final ctx = _pendingDownloadContext.remove(globalKey); final ctx = _pendingDownloadContext.remove(globalKey);
if (ctx == null) return; if (ctx == null) return;
if (_completingKeys.contains(globalKey)) return; if (_completingKeys.contains(globalKey)) return;
@@ -737,6 +765,7 @@ class DownloadManagerService {
appLogger.d('Ignoring failure event for $globalKey: completion in progress'); appLogger.d('Ignoring failure event for $globalKey: completion in progress');
return; return;
} }
_cancelDownloadTimers(globalKey);
_pendingDownloadContext.remove(globalKey); _pendingDownloadContext.remove(globalKey);
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _database.getDownloadedMedia(globalKey);
@@ -767,7 +796,6 @@ class DownloadManagerService {
); );
await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage);
await _database.removeFromQueue(globalKey); await _database.removeFromQueue(globalKey);
_autoRetryTimers[globalKey]?.cancel();
_autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () { _autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () {
_autoRetryTimers.remove(globalKey); _autoRetryTimers.remove(globalKey);
_performAutoRetry(globalKey); _performAutoRetry(globalKey);
@@ -791,6 +819,7 @@ class DownloadManagerService {
appLogger.d('Ignoring permanent failure event for $globalKey: completion in progress'); appLogger.d('Ignoring permanent failure event for $globalKey: completion in progress');
return; return;
} }
_cancelDownloadTimers(globalKey);
_pendingDownloadContext.remove(globalKey); _pendingDownloadContext.remove(globalKey);
final existing = await _database.getDownloadedMedia(globalKey); final existing = await _database.getDownloadedMedia(globalKey);
@@ -840,8 +869,8 @@ class DownloadManagerService {
} }
_completingKeys.add(globalKey); _completingKeys.add(globalKey);
try { try {
// Flush any pending debounced progress write before completing // Flush any pending debounced progress write + cancel any scheduled retry
_progressDebounceTimers.remove(globalKey)?.cancel(); _cancelDownloadTimers(globalKey);
// Fresh DB check — bail if already completed (guards against race with orphan scan) // Fresh DB check — bail if already completed (guards against race with orphan scan)
final existingCheck = await _database.getDownloadedMedia(globalKey); final existingCheck = await _database.getDownloadedMedia(globalKey);
@@ -1242,6 +1271,7 @@ class DownloadManagerService {
_pausingKeys.add(globalKey); _pausingKeys.add(globalKey);
try { try {
_cancelDownloadTimers(globalKey);
final bgTaskId = await _database.getBgTaskId(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey);
if (bgTaskId != null) { if (bgTaskId != null) {
final task = await FileDownloader().taskForId(bgTaskId); final task = await FileDownloader().taskForId(bgTaskId);
@@ -1302,7 +1332,7 @@ class DownloadManagerService {
/// Cancel a download /// Cancel a download
Future<void> cancelDownload(String globalKey) async { Future<void> cancelDownload(String globalKey) async {
_autoRetryTimers.remove(globalKey)?.cancel(); _cancelDownloadTimers(globalKey);
final bgTaskId = await _database.getBgTaskId(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey);
if (bgTaskId != null) { if (bgTaskId != null) {
await FileDownloader().cancelTaskWithId(bgTaskId); await FileDownloader().cancelTaskWithId(bgTaskId);
@@ -1314,7 +1344,7 @@ class DownloadManagerService {
/// Delete a downloaded item and its files /// Delete a downloaded item and its files
Future<void> deleteDownload(String globalKey) async { Future<void> deleteDownload(String globalKey) async {
_autoRetryTimers.remove(globalKey)?.cancel(); _cancelDownloadTimers(globalKey);
// Cancel if actively downloading via background_downloader // Cancel if actively downloading via background_downloader
final bgTaskId = await _database.getBgTaskId(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey);
if (bgTaskId != null) { if (bgTaskId != null) {
@@ -1892,6 +1922,10 @@ class DownloadManagerService {
timer.cancel(); timer.cancel();
} }
_autoRetryTimers.clear(); _autoRetryTimers.clear();
_pendingDownloadContext.clear();
_pendingSupplementaryDownloads.clear();
_completingKeys.clear();
_pausingKeys.clear();
_progressController.close(); _progressController.close();
_deletionProgressController.close(); _deletionProgressController.close();
} }
+21 -3
View File
@@ -9,6 +9,19 @@ import '../utils/app_logger.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import 'settings_service.dart'; import 'settings_service.dart';
/// Thrown when the downloads storage layer cannot create or access a directory
/// (permission denied, quota exceeded, SAF permission revoked, etc.).
class DownloadStorageException implements Exception {
final String message;
final String path;
final Object cause;
DownloadStorageException(this.message, this.path, this.cause);
@override
String toString() => 'DownloadStorageException: $message (path: $path, cause: $cause)';
}
class DownloadStorageService { class DownloadStorageService {
static DownloadStorageService? _instance; static DownloadStorageService? _instance;
static DownloadStorageService get instance => _instance ??= DownloadStorageService._(); static DownloadStorageService get instance => _instance ??= DownloadStorageService._();
@@ -219,12 +232,17 @@ class DownloadStorageService {
.trim(); .trim();
} }
/// Ensure a directory exists, creating it if necessary /// Ensure a directory exists, creating it if necessary.
/// `Directory.create(recursive: true)` is idempotent — it no-ops if the
/// directory already exists.
Future<Directory> _ensureDirectoryExists(Directory dir) async { Future<Directory> _ensureDirectoryExists(Directory dir) async {
if (!await dir.exists()) { try {
await dir.create(recursive: true); await dir.create(recursive: true);
return dir;
} catch (e, st) {
appLogger.e('Failed to ensure directory exists: ${dir.path}', error: e, stackTrace: st);
throw DownloadStorageException('Cannot create directory', dir.path, e);
} }
return dir;
} }
/// Format a media title with optional year: "Title (YYYY)" or "Title" /// Format a media title with optional year: "Title (YYYY)" or "Title"
+24 -32
View File
@@ -5,9 +5,11 @@ import '../models/download_models.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../utils/episode_collection.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import 'download_manager_service.dart'; import 'download_manager_service.dart';
import 'multi_server_manager.dart'; import 'multi_server_manager.dart';
import 'offline_mode_source.dart';
import 'plex_client.dart'; import 'plex_client.dart';
/// Sync-rule filter values stored in `SyncRules.downloadFilter`. /// Sync-rule filter values stored in `SyncRules.downloadFilter`.
@@ -40,10 +42,18 @@ class SyncRuleExecutor {
static const Duration _cooldownWifi = Duration(minutes: 30); static const Duration _cooldownWifi = Duration(minutes: 30);
static const Duration _cooldownCellular = Duration(hours: 3); static const Duration _cooldownCellular = Duration(hours: 3);
OfflineModeSource? _offlineSource;
SyncRuleExecutor({required AppDatabase database}) : _database = database; SyncRuleExecutor({required AppDatabase database}) : _database = database;
bool get isExecuting => _isExecuting; bool get isExecuting => _isExecuting;
/// Inject the offline-mode source so we can skip running rules when the
/// device has no Plex connectivity (every `getChildren` call would fail).
void setOfflineSource(OfflineModeSource? source) {
_offlineSource = source;
}
/// Execute every enabled sync rule. /// Execute every enabled sync rule.
/// ///
/// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only /// The adaptive cooldown (30 min on WiFi/Ethernet, 3 h on cellular) only
@@ -67,6 +77,11 @@ class SyncRuleExecutor {
return []; return [];
} }
if (_offlineSource?.isOffline ?? false) {
appLogger.d('Skipping sync rules — offline');
return [];
}
// Read connectivity once for both the WiFi-only gate and the cooldown pick. // Read connectivity once for both the WiFi-only gate and the cooldown pick.
final List<ConnectivityResult> connectivity = await _readConnectivity(); final List<ConnectivityResult> connectivity = await _readConnectivity();
if (await DownloadManagerService.shouldBlockDownloadOnCellularWith(connectivity)) { if (await DownloadManagerService.shouldBlockDownloadOnCellularWith(connectivity)) {
@@ -134,6 +149,11 @@ class SyncRuleExecutor {
return null; return null;
} }
if (_offlineSource?.isOffline ?? false) {
appLogger.d('Skipping single sync rule $globalKey — offline');
return null;
}
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) { if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
appLogger.d('Skipping single sync rule $globalKey — cellular download blocked'); appLogger.d('Skipping single sync rule $globalKey — cellular download blocked');
return null; return null;
@@ -210,9 +230,9 @@ class SyncRuleExecutor {
}) async { }) async {
final unwatchedEpisodes = <PlexMetadata>[]; final unwatchedEpisodes = <PlexMetadata>[];
if (rule.targetType == ContentTypes.show) { if (rule.targetType == ContentTypes.show) {
await _collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); await collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes);
} else { } else {
await _collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes); await collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: unwatchedEpisodes);
} }
if (unwatchedEpisodes.isEmpty) { if (unwatchedEpisodes.isEmpty) {
@@ -333,9 +353,9 @@ class SyncRuleExecutor {
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break; if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break;
out.add(item); out.add(item);
case ContentTypes.show: case ContentTypes.show:
await _collectEpisodesForShow(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); await collectEpisodesForShow(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out);
case ContentTypes.season: case ContentTypes.season:
await _collectEpisodesForSeason(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out); await collectEpisodesForSeason(client, item.ratingKey, unwatchedOnly: unwatchedOnly, out: out);
default: default:
// Skip music, clips, nested collections/playlists, unknown types. // Skip music, clips, nested collections/playlists, unknown types.
break; break;
@@ -343,34 +363,6 @@ class SyncRuleExecutor {
} }
} }
Future<void> _collectEpisodesForShow(
PlexClient client,
String showRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> out,
}) async {
final seasons = await client.getChildren(showRatingKey);
for (final season in seasons) {
if (season.type == ContentTypes.season) {
await _collectEpisodesForSeason(client, season.ratingKey, unwatchedOnly: unwatchedOnly, out: out);
}
}
}
Future<void> _collectEpisodesForSeason(
PlexClient client,
String seasonRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> out,
}) async {
final episodes = await client.getChildren(seasonRatingKey);
for (final ep in episodes) {
if (ep.type != ContentTypes.episode) continue;
if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue;
out.add(ep);
}
}
static bool _isActiveDownload(DownloadProgress? p) => static bool _isActiveDownload(DownloadProgress? p) =>
p != null && p != null &&
(p.status == DownloadStatus.completed || (p.status == DownloadStatus.completed ||
+50 -7
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_ids.dart';
import '../../utils/app_logger.dart';
import 'trakt_constants.dart'; import 'trakt_constants.dart';
/// One pending watched/unwatched push waiting to be drained to Trakt. /// One pending watched/unwatched push waiting to be drained to Trakt.
@@ -73,23 +75,43 @@ class TraktSyncQueueItem {
/// ///
/// Cap at [maxAttempts] before dropping permanently — matches /// Cap at [maxAttempts] before dropping permanently — matches
/// `OfflineWatchSyncService.maxSyncAttempts`. /// `OfflineWatchSyncService.maxSyncAttempts`.
///
/// Serialises all writes (`add`, `save`, `drainWith`) through a Completer chain
/// so concurrent `add()` calls don't interleave read-modify-write and lose items.
class TraktSyncQueue { class TraktSyncQueue {
static const String _baseKey = 'trakt_sync_queue'; static const String _baseKey = 'trakt_sync_queue';
static const int maxAttempts = 5; static const int maxAttempts = 5;
Future<void> _writeLock = Future<void>.value();
Future<T> _locked<T>(Future<T> Function() action) {
final previous = _writeLock;
final completer = Completer<void>();
_writeLock = completer.future;
return previous.then((_) => action()).whenComplete(completer.complete);
}
Future<List<TraktSyncQueueItem>> load(String userUuid) async { Future<List<TraktSyncQueueItem>> load(String userUuid) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(traktUserKey(userUuid, _baseKey)); final key = traktUserKey(userUuid, _baseKey);
final raw = prefs.getString(key);
if (raw == null) return []; if (raw == null) return [];
try { try {
final list = json.decode(raw) as List<dynamic>; final list = json.decode(raw) as List<dynamic>;
return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList(); return list.map((e) => TraktSyncQueueItem.fromJson(e as Map<String, dynamic>)).toList();
} catch (_) { } catch (e, st) {
appLogger.e('Trakt sync queue parse failed, discarding', error: e, stackTrace: st);
await prefs.setString(traktUserKey(userUuid, '${_baseKey}_corrupt'), raw);
await prefs.remove(key);
return []; return [];
} }
} }
Future<void> save(String userUuid, List<TraktSyncQueueItem> items) async { Future<void> save(String userUuid, List<TraktSyncQueueItem> items) {
return _locked(() => _saveRaw(userUuid, items));
}
Future<void> _saveRaw(String userUuid, List<TraktSyncQueueItem> items) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final key = traktUserKey(userUuid, _baseKey); final key = traktUserKey(userUuid, _baseKey);
if (items.isEmpty) { if (items.isEmpty) {
@@ -99,9 +121,30 @@ class TraktSyncQueue {
} }
} }
Future<void> add(String userUuid, TraktSyncQueueItem item) async { Future<void> add(String userUuid, TraktSyncQueueItem item) {
final items = await load(userUuid); return _locked(() async {
items.add(item); final items = await load(userUuid);
await save(userUuid, items); items.add(item);
await _saveRaw(userUuid, items);
});
}
/// Atomic drain: load the queue, run [processor] for each item, and save the
/// items the processor decided to retain. Holds the write lock for the whole
/// cycle so concurrent `add()`s wait until the drain completes (no lost items).
///
/// [processor] returns `null` to drop the item, or a (possibly mutated) item
/// to retain for the next drain (e.g. `item.incrementAttempts()`).
Future<void> drainWith(String userUuid, Future<TraktSyncQueueItem?> Function(TraktSyncQueueItem) processor) {
return _locked(() async {
final items = await load(userUuid);
if (items.isEmpty) return;
final remaining = <TraktSyncQueueItem>[];
for (final item in items) {
final keep = await processor(item);
if (keep != null) remaining.add(keep);
}
await _saveRaw(userUuid, remaining);
});
} }
} }
+53 -12
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection';
import '../../models/trakt/trakt_ids.dart'; import '../../models/trakt/trakt_ids.dart';
import '../../models/trakt/trakt_scrobble_request.dart'; import '../../models/trakt/trakt_scrobble_request.dart';
@@ -40,6 +41,12 @@ class TraktSyncService {
/// key GUID cache survives a binge-watch session. /// key GUID cache survives a binge-watch session.
final Map<String, TraktGuidResolver> _resolvers = {}; final Map<String, TraktGuidResolver> _resolvers = {};
/// Fallback buffer for items that failed to persist to the on-disk queue
/// (e.g. SharedPreferences write threw). Retried on next `flushQueue`.
/// Bounded to keep memory pressure finite; oldest items drop first.
static const int _maxInMemoryFallback = 100;
final Queue<TraktSyncQueueItem> _inMemoryFallback = Queue<TraktSyncQueueItem>();
bool _isFlushing = false; bool _isFlushing = false;
Future<void> initialize({required MultiServerManager serverManager}) async { Future<void> initialize({required MultiServerManager serverManager}) async {
@@ -50,7 +57,11 @@ class TraktSyncService {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
_isEnabled = settings.getEnableTraktWatchedSync(); _isEnabled = settings.getEnableTraktWatchedSync();
_subscription = WatchStateNotifier().stream.listen(_onWatchStateEvent); _subscription = WatchStateNotifier().stream.listen(
_onWatchStateEvent,
onError: (Object e, StackTrace st) =>
appLogger.w('Trakt sync: watch event handler error', error: e, stackTrace: st),
);
} }
Future<void> setEnabled(bool enabled) async { Future<void> setEnabled(bool enabled) async {
@@ -166,7 +177,7 @@ class TraktSyncService {
Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async { Future<void> _trySendOrQueue(TraktSyncQueueItem item, TraktScrobbleRequest body) async {
final client = _client; final client = _client;
if (client == null) { if (client == null) {
await _queue.add(_activeUserUuid, item); await _persistOrBuffer(item);
return; return;
} }
try { try {
@@ -174,7 +185,27 @@ class TraktSyncService {
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok'); appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} → ok');
} catch (e) { } catch (e) {
appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e); appLogger.d('Trakt sync: ${item.op.name} ${item.ratingKey} failed, queuing', error: e);
await _persistOrBuffer(item);
}
}
/// Persist an item to the on-disk queue; fall back to a bounded in-memory
/// buffer if the disk write throws (e.g. disk full, SAF permission revoked).
/// Retried at the start of the next `flushQueue` run.
Future<void> _persistOrBuffer(TraktSyncQueueItem item) async {
try {
await _queue.add(_activeUserUuid, item); await _queue.add(_activeUserUuid, item);
} catch (e, st) {
appLogger.e(
'Trakt sync: queue persist failed for ${item.op.name} ${item.ratingKey}, buffering in memory',
error: e,
stackTrace: st,
);
if (_inMemoryFallback.length >= _maxInMemoryFallback) {
final dropped = _inMemoryFallback.removeFirst();
appLogger.w('Trakt sync: in-memory fallback full, dropping ${dropped.op.name} ${dropped.ratingKey}');
}
_inMemoryFallback.addLast(item);
} }
} }
@@ -193,31 +224,41 @@ class TraktSyncService {
if (client == null) return; if (client == null) return;
_isFlushing = true; _isFlushing = true;
try { try {
final items = await _queue.load(_activeUserUuid); await _recoverInMemoryFallback();
if (items.isEmpty) return;
final remaining = <TraktSyncQueueItem>[]; await _queue.drainWith(_activeUserUuid, (item) async {
for (final item in items) {
if (item.attempts >= TraktSyncQueue.maxAttempts) { if (item.attempts >= TraktSyncQueue.maxAttempts) {
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts'); appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
continue; return null;
} }
try { try {
await _dispatch(client, item, _bodyFor(item)); await _dispatch(client, item, _bodyFor(item));
appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}'); appLogger.d('Trakt sync: drained ${item.op.name} ${item.ratingKey}');
await Future<void>.delayed(_queueRequestSpacing);
return null;
} catch (e) { } catch (e) {
appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e); appLogger.d('Trakt sync: drain failed for ${item.ratingKey}, will retry', error: e);
remaining.add(item.incrementAttempts()); await Future<void>.delayed(_queueRequestSpacing);
return item.incrementAttempts();
} }
await Future<void>.delayed(_queueRequestSpacing); });
}
await _queue.save(_activeUserUuid, remaining);
} finally { } finally {
_isFlushing = false; _isFlushing = false;
} }
} }
/// Try to move items buffered in memory (because prior disk writes failed)
/// back onto the persistent queue. Best-effort; items that still can't be
/// persisted stay in the buffer for the next flush.
Future<void> _recoverInMemoryFallback() async {
if (_inMemoryFallback.isEmpty) return;
final snapshot = List<TraktSyncQueueItem>.from(_inMemoryFallback);
_inMemoryFallback.clear();
for (final item in snapshot) {
await _persistOrBuffer(item);
}
}
TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) { TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) {
return switch (item.kind) { return switch (item.kind) {
TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids), TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids),
-5
View File
@@ -89,12 +89,7 @@ extension PlexMetadataType on PlexMetadata {
bool get isMovie => _lowerType == ContentTypes.movie; bool get isMovie => _lowerType == ContentTypes.movie;
bool get isSeason => _lowerType == ContentTypes.season; bool get isSeason => _lowerType == ContentTypes.season;
bool get isEpisode => _lowerType == ContentTypes.episode; bool get isEpisode => _lowerType == ContentTypes.episode;
bool get isArtist => _lowerType == ContentTypes.artist;
bool get isAlbum => _lowerType == ContentTypes.album;
bool get isTrack => _lowerType == ContentTypes.track;
bool get isCollection => _lowerType == ContentTypes.collection; bool get isCollection => _lowerType == ContentTypes.collection;
bool get isPlaylist => _lowerType == ContentTypes.playlist;
bool get isClip => _lowerType == ContentTypes.clip;
bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType); bool get isMusicContent => ContentTypes.musicTypes.contains(_lowerType);
bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType); bool get isVideoContent => ContentTypes.videoTypes.contains(_lowerType);
+52
View File
@@ -0,0 +1,52 @@
import '../models/plex_metadata.dart';
import '../services/plex_client.dart';
import '../utils/app_logger.dart';
import '../utils/content_utils.dart';
/// Walk the children of a show and collect every episode into [out].
///
/// - Per-season fetch failures are logged and skipped (one bad season doesn't
/// discard progress from the others).
/// - A failure to fetch the show's own children is logged and leaves [out]
/// empty.
/// - [unwatchedOnly] skips episodes that are watched and have no active
/// progress.
Future<void> collectEpisodesForShow(
PlexClient client,
String showRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> out,
}) async {
final List<PlexMetadata> seasons;
try {
seasons = await client.getChildren(showRatingKey);
} catch (e) {
appLogger.w('Episode collection: show $showRatingKey getChildren failed, skipping', error: e);
return;
}
for (final season in seasons) {
if (season.type != ContentTypes.season) continue;
try {
await collectEpisodesForSeason(client, season.ratingKey, unwatchedOnly: unwatchedOnly, out: out);
} catch (e) {
appLogger.w('Episode collection: season ${season.ratingKey} fetch failed, skipping', error: e);
}
}
}
/// Fetch the episodes of a season and append the ones passing [unwatchedOnly]
/// to [out]. Throws if the underlying `getChildren` fails — callers that want
/// per-season resilience should wrap in try/catch (see [collectEpisodesForShow]).
Future<void> collectEpisodesForSeason(
PlexClient client,
String seasonRatingKey, {
required bool unwatchedOnly,
required List<PlexMetadata> out,
}) async {
final episodes = await client.getChildren(seasonRatingKey);
for (final ep in episodes) {
if (ep.type != ContentTypes.episode) continue;
if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue;
out.add(ep);
}
}
-25
View File
@@ -21,35 +21,10 @@ class PlexCacheParser {
return list.first as Map<String, dynamic>; return list.first as Map<String, dynamic>;
} }
/// Check if a cached response has valid metadata
static bool hasMetadata(Map<String, dynamic>? cached) {
final list = extractMetadataList(cached);
return list != null && list.isNotEmpty;
}
/// Extract Directory list from a cached response (for libraries, playlists)
static List<dynamic>? extractDirectoryList(Map<String, dynamic>? cached) {
if (cached == null) return null;
return cached['MediaContainer']?['Directory'] as List?;
}
/// Extract Hub list from a cached response
static List<dynamic>? extractHubList(Map<String, dynamic>? cached) {
if (cached == null) return null;
return cached['MediaContainer']?['Hub'] as List?;
}
/// Extract Chapter list from the first metadata item /// Extract Chapter list from the first metadata item
static List<dynamic>? extractChapters(Map<String, dynamic>? cached) { static List<dynamic>? extractChapters(Map<String, dynamic>? cached) {
final metadata = extractFirstMetadata(cached); final metadata = extractFirstMetadata(cached);
if (metadata == null) return null; if (metadata == null) return null;
return metadata['Chapter'] as List?; return metadata['Chapter'] as List?;
} }
/// Extract Marker list from the first metadata item
static List<dynamic>? extractMarkers(Map<String, dynamic>? cached) {
final metadata = extractFirstMetadata(cached);
if (metadata == null) return null;
return metadata['Marker'] as List?;
}
} }
-11
View File
@@ -243,15 +243,4 @@ class PlexImageHelper {
return true; return true;
} }
/// Creates a consistent cache key for rounded dimensions
static String generateCacheKey({
required String originalPath,
required int width,
required int height,
String? serverId,
}) {
final serverPrefix = serverId != null ? '${serverId}_' : '';
return '${serverPrefix}transcode_${width}x${height}_${originalPath.hashCode}';
}
} }
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../models/download_models.dart';
import '../widgets/app_icon.dart';
/// Visual weight preset.
///
/// - [muted] blends the status color with the surrounding muted text color
/// (used in compact list contexts like episode rows where a saturated color
/// would fight the primary content).
/// - [saturated] uses the status color directly (used in tree view / expanded
/// contexts where the status is the primary signal).
enum DownloadStatusIconVariant { muted, saturated }
/// Renders a compact status indicator for a download: queued/paused/failed/etc.
/// When [status] is [DownloadStatus.downloading] and [progress] is non-null,
/// renders a dual-ring progress indicator instead of a static icon.
///
/// Returns `SizedBox.shrink()` for any status that shouldn't have a visible
/// indicator in the caller's context (e.g. partial).
class DownloadStatusIcon extends StatelessWidget {
final DownloadStatus? status;
final double size;
final DownloadStatusIconVariant variant;
/// Optional 0.01.0 progress for the downloading state's ring.
/// If null while downloading, shows an indeterminate spinner.
final double? progress;
/// Color to blend with in [DownloadStatusIconVariant.muted]. Required when
/// variant=muted (usually `tokens(context).textMuted`).
final Color? mutedBase;
/// Optional override for the primary color (e.g. used in downloading
/// variant=muted to pick the theme's primary rather than a fixed blue).
final Color? overrideColor;
const DownloadStatusIcon({
super.key,
required this.status,
this.size = 16,
this.variant = DownloadStatusIconVariant.saturated,
this.progress,
this.mutedBase,
this.overrideColor,
});
Color _tint(Color base) {
if (variant == DownloadStatusIconVariant.saturated || mutedBase == null) return base;
return Color.lerp(mutedBase, base, 0.3) ?? base;
}
@override
Widget build(BuildContext context) {
final s = status;
if (s == null) return const SizedBox.shrink();
switch (s) {
case DownloadStatus.queued:
return AppIcon(Symbols.schedule_rounded, fill: 1, size: size, color: _tint(Colors.orange));
case DownloadStatus.downloading:
// No progress value — render a static "downloading" icon (callers
// without per-item progress, e.g. the download tree view).
if (progress == null) {
return AppIcon(Symbols.downloading_rounded, fill: 1, size: size, color: _tint(overrideColor ?? Colors.blue));
}
final primary = overrideColor ?? Theme.of(context).colorScheme.primary;
final tinted = _tint(primary);
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: 1.0,
strokeWidth: size * 0.1,
valueColor: AlwaysStoppedAnimation<Color>(tinted.withValues(alpha: 0.3)),
),
CircularProgressIndicator(
value: progress,
strokeWidth: size * 0.1,
valueColor: AlwaysStoppedAnimation<Color>(tinted),
),
],
),
);
case DownloadStatus.paused:
return AppIcon(
Symbols.pause_circle_outline_rounded,
fill: 1,
size: size,
color: _tint(variant == DownloadStatusIconVariant.muted ? Colors.amber : Colors.grey),
);
case DownloadStatus.failed:
return AppIcon(
variant == DownloadStatusIconVariant.muted ? Symbols.error_outline_rounded : Symbols.error_rounded,
fill: 1,
size: size,
color: _tint(Colors.red),
);
case DownloadStatus.cancelled:
return AppIcon(Symbols.cancel_rounded, fill: 1, size: size, color: _tint(Colors.grey));
case DownloadStatus.completed:
return AppIcon(
variant == DownloadStatusIconVariant.muted
? Symbols.file_download_done_rounded
: Symbols.check_circle_rounded,
fill: 1,
size: size,
color: _tint(Colors.green),
);
case DownloadStatus.partial:
return AppIcon(Symbols.downloading_rounded, fill: 1, size: size, color: _tint(Colors.orange));
}
}
}
/// Indeterminate spinner used while the download is being queued (pre-status).
/// Separate widget because it doesn't correspond to a [DownloadStatus] value.
class DownloadQueueingSpinner extends StatelessWidget {
final double size;
final Color? color;
const DownloadQueueingSpinner({super.key, this.size = 12, this.color});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(strokeWidth: size * 0.125, color: color),
);
}
}
+2 -35
View File
@@ -7,6 +7,7 @@ import '../models/download_models.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import 'download_status_icon.dart';
/// Represents a node in the download tree /// Represents a node in the download tree
class DownloadTreeNode { class DownloadTreeNode {
@@ -707,41 +708,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
} }
Widget _buildStatusIcon(DownloadStatus status) { Widget _buildStatusIcon(DownloadStatus status) {
IconData iconData; return DownloadStatusIcon(status: status, size: 20);
Color? color;
switch (status) {
case DownloadStatus.downloading:
iconData = Symbols.downloading_rounded;
color = Colors.blue;
break;
case DownloadStatus.queued:
iconData = Symbols.schedule_rounded;
color = Colors.orange;
break;
case DownloadStatus.paused:
iconData = Symbols.pause_circle_outline_rounded;
color = Colors.grey;
break;
case DownloadStatus.completed:
iconData = Symbols.check_circle_rounded;
color = Colors.green;
break;
case DownloadStatus.failed:
iconData = Symbols.error_rounded;
color = Colors.red;
break;
case DownloadStatus.cancelled:
iconData = Symbols.cancel_rounded;
color = Colors.grey;
break;
case DownloadStatus.partial:
iconData = Symbols.downloading_rounded;
color = Colors.orange;
break;
}
return AppIcon(iconData, fill: 1, size: 20, color: color);
} }
String _getNodeSummary() { String _getNodeSummary() {
+44 -87
View File
@@ -11,6 +11,7 @@ import '../providers/download_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../widgets/collapsible_text.dart'; import '../widgets/collapsible_text.dart';
import '../widgets/download_status_icon.dart';
import '../widgets/plex_optimized_image.dart'; import '../widgets/plex_optimized_image.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
@@ -238,99 +239,30 @@ class _EpisodeCardState extends State<EpisodeCard> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Episode number and title with download status // Episode number and title with download status
Consumer<DownloadProvider>( Selector<DownloadProvider, _DownloadSlice>(
builder: (context, downloadProvider, _) { selector: (_, p) => _DownloadSlice.from(
p.getProgress(widget.episode.globalKey),
p.isQueueing(widget.episode.globalKey),
),
builder: (context, slice, _) {
// Build download status icon based on state // Build download status icon based on state
Widget? downloadStatusIcon; Widget? downloadStatusIcon;
// Only show download status in online mode // Only show download status in online mode
if (!widget.isOffline && widget.episode.serverId != null) { if (!widget.isOffline && widget.episode.serverId != null) {
final globalKey = widget.episode.globalKey; final status = slice.status;
final progress = downloadProvider.getProgress(globalKey); final mutedBase = tokens(context).textMuted;
final isQueueing = downloadProvider.isQueueing(globalKey);
// Helper to get status-specific muted color if (slice.isQueueing) {
Color getMutedColor(Color baseColor) { downloadStatusIcon = DownloadQueueingSpinner(size: 12, color: mutedBase);
return Color.lerp( } else if (status != null) {
tokens(context).textMuted, final iconSize = status == DownloadStatus.downloading ? 14.0 : 12.0;
baseColor, downloadStatusIcon = DownloadStatusIcon(
0.3, // 30% of the status color, 70% muted status: status,
)!; size: iconSize,
} variant: DownloadStatusIconVariant.muted,
mutedBase: mutedBase,
if (isQueueing) { progress: slice.progressPercent,
// Queueing state - building queue
downloadStatusIcon = SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5, color: tokens(context).textMuted),
);
} else if (progress?.status == DownloadStatus.queued) {
// Queued state - waiting to download
downloadStatusIcon = AppIcon(
Symbols.schedule_rounded,
fill: 1,
size: 12,
color: getMutedColor(Colors.orange),
);
} else if (progress?.status == DownloadStatus.downloading) {
// Downloading state - active download with radial progress
downloadStatusIcon = SizedBox(
width: 14,
height: 14,
child: Stack(
alignment: Alignment.center,
children: [
// Background circle
CircularProgressIndicator(
value: 1.0,
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(
getMutedColor(Theme.of(context).colorScheme.primary).withValues(alpha: 0.3),
),
),
// Progress circle
CircularProgressIndicator(
value: progress?.progressPercent,
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(
getMutedColor(Theme.of(context).colorScheme.primary),
),
),
],
),
);
} else if (progress?.status == DownloadStatus.paused) {
// Paused state - download paused
downloadStatusIcon = AppIcon(
Symbols.pause_circle_outline_rounded,
fill: 1,
size: 12,
color: getMutedColor(Colors.amber),
);
} else if (progress?.status == DownloadStatus.failed) {
// Failed state - download failed
downloadStatusIcon = AppIcon(
Symbols.error_outline_rounded,
fill: 1,
size: 12,
color: getMutedColor(Colors.red),
);
} else if (progress?.status == DownloadStatus.cancelled) {
// Cancelled state - download cancelled
downloadStatusIcon = AppIcon(
Symbols.cancel_rounded,
fill: 1,
size: 12,
color: getMutedColor(Colors.grey),
);
} else if (progress?.status == DownloadStatus.completed) {
// Completed state - download complete
downloadStatusIcon = AppIcon(
Symbols.file_download_done_rounded,
fill: 1,
size: 12,
color: getMutedColor(Colors.green),
); );
} }
// Note: No icon shown if not downloaded (null) // Note: No icon shown if not downloaded (null)
@@ -437,3 +369,28 @@ class _EpisodeCardState extends State<EpisodeCard> {
return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32)); return const PlaceholderContainer(child: AppIcon(Symbols.movie_rounded, fill: 1, size: 32));
} }
} }
/// Captures only primitives so Selector equality avoids rebuilds on unrelated
/// download ticks (e.g. other episodes, unused `DownloadProgress` fields).
class _DownloadSlice {
final DownloadStatus? status;
final double? progressPercent;
final bool isQueueing;
const _DownloadSlice({required this.status, required this.progressPercent, required this.isQueueing});
factory _DownloadSlice.from(DownloadProgress? p, bool isQueueing) =>
_DownloadSlice(status: p?.status, progressPercent: p?.progressPercent, isQueueing: isQueueing);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _DownloadSlice &&
other.status == status &&
other.progressPercent == progressPercent &&
other.isQueueing == isQueueing;
}
@override
int get hashCode => Object.hash(status, progressPercent, isQueueing);
}
+4 -2
View File
@@ -881,7 +881,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
if (sectionId == null) { if (sectionId == null) {
if (context.mounted) { if (context.mounted) {
showErrorSnackBar(context, 'Unable to determine library section for this item'); showErrorSnackBar(context, t.messages.unableToDetermineLibrarySection);
} }
return; return;
} }
@@ -1514,7 +1514,9 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
return ListTile( return ListTile(
leading: const AppIcon(Symbols.collections_rounded, fill: 1), leading: const AppIcon(Symbols.collections_rounded, fill: 1),
title: Text(collection.title!), title: Text(collection.title!),
subtitle: collection.childCount != null ? Text('${collection.childCount} items') : null, subtitle: collection.childCount != null
? Text(t.playlists.itemCount(count: collection.childCount!))
: null,
onTap: () => Navigator.pop(context, collection.ratingKey), onTap: () => Navigator.pop(context, collection.ratingKey),
); );
}, },