refactor: trakt/download hardening and cleanup sweep
This commit is contained in:
+524
-1461
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -426,6 +426,10 @@
|
|||||||
"errorLoadingSeries": "シリーズの読み込みエラー",
|
"errorLoadingSeries": "シリーズの読み込みエラー",
|
||||||
"errorLoadingSeason": "シーズンの読み込みエラー",
|
"errorLoadingSeason": "シーズンの読み込みエラー",
|
||||||
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
"musicNotSupported": "音楽の再生はまだサポートされていません",
|
||||||
|
"noDescriptionAvailable": "説明はありません",
|
||||||
|
"noProfilesAvailable": "利用可能なプロフィールがありません",
|
||||||
|
"contactAdminForProfiles": "プロフィールを追加するにはPlex管理者にお問い合わせください",
|
||||||
|
"unableToDetermineLibrarySection": "このアイテムのライブラリセクションを判別できません",
|
||||||
"logsCleared": "ログをクリアしました",
|
"logsCleared": "ログをクリアしました",
|
||||||
"logsCopied": "ログをクリップボードにコピーしました",
|
"logsCopied": "ログをクリップボードにコピーしました",
|
||||||
"noLogsAvailable": "ログがありません",
|
"noLogsAvailable": "ログがありません",
|
||||||
|
|||||||
@@ -426,6 +426,10 @@
|
|||||||
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
"errorLoadingSeries": "시리즈 로딩 중 오류",
|
||||||
"errorLoadingSeason": "시즌 로딩 중 오류",
|
"errorLoadingSeason": "시즌 로딩 중 오류",
|
||||||
"musicNotSupported": "음악 재생 미지원",
|
"musicNotSupported": "음악 재생 미지원",
|
||||||
|
"noDescriptionAvailable": "설명이 없습니다",
|
||||||
|
"noProfilesAvailable": "사용 가능한 프로필이 없습니다",
|
||||||
|
"contactAdminForProfiles": "프로필을 추가하려면 Plex 관리자에게 문의하세요",
|
||||||
|
"unableToDetermineLibrarySection": "이 항목의 라이브러리 섹션을 확인할 수 없습니다",
|
||||||
"logsCleared": "로그가 삭제 되었습니다",
|
"logsCleared": "로그가 삭제 되었습니다",
|
||||||
"logsCopied": "로그가 클립보드에 복사 되었습니다",
|
"logsCopied": "로그가 클립보드에 복사 되었습니다",
|
||||||
"noLogsAvailable": "사용 가능한 로그가 없습니다",
|
"noLogsAvailable": "사용 가능한 로그가 없습니다",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -426,6 +426,10 @@
|
|||||||
"errorLoadingSeries": "Ошибка загрузки сериала",
|
"errorLoadingSeries": "Ошибка загрузки сериала",
|
||||||
"errorLoadingSeason": "Ошибка загрузки сезона",
|
"errorLoadingSeason": "Ошибка загрузки сезона",
|
||||||
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
|
"musicNotSupported": "Воспроизведение музыки пока не поддерживается",
|
||||||
|
"noDescriptionAvailable": "Описание недоступно",
|
||||||
|
"noProfilesAvailable": "Профили недоступны",
|
||||||
|
"contactAdminForProfiles": "Обратитесь к администратору Plex, чтобы добавить профили",
|
||||||
|
"unableToDetermineLibrarySection": "Не удаётся определить раздел библиотеки для этого элемента",
|
||||||
"logsCleared": "Логи очищены",
|
"logsCleared": "Логи очищены",
|
||||||
"logsCopied": "Логи скопированы в буфер обмена",
|
"logsCopied": "Логи скопированы в буфер обмена",
|
||||||
"noLogsAvailable": "Логи отсутствуют",
|
"noLogsAvailable": "Логи отсутствуют",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' => 'テレビ番組',
|
||||||
|
|||||||
@@ -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 프로그램',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' => 'Сериалы',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' => '剧集',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -426,6 +426,10 @@
|
|||||||
"errorLoadingSeries": "加载系列时出错",
|
"errorLoadingSeries": "加载系列时出错",
|
||||||
"errorLoadingSeason": "加载季时出错",
|
"errorLoadingSeason": "加载季时出错",
|
||||||
"musicNotSupported": "尚不支持播放音乐",
|
"musicNotSupported": "尚不支持播放音乐",
|
||||||
|
"noDescriptionAvailable": "暂无描述",
|
||||||
|
"noProfilesAvailable": "没有可用的用户",
|
||||||
|
"contactAdminForProfiles": "请联系您的 Plex 管理员添加用户",
|
||||||
|
"unableToDetermineLibrarySection": "无法确定此项目的库分区",
|
||||||
"logsCleared": "日志已清除",
|
"logsCleared": "日志已清除",
|
||||||
"logsCopied": "日志已复制到剪贴板",
|
"logsCopied": "日志已复制到剪贴板",
|
||||||
"noLogsAvailable": "没有可用日志",
|
"noLogsAvailable": "没有可用日志",
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -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
@@ -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)';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.0–1.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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user