fix: prevent async state and platform regressions

This commit is contained in:
edde746
2026-07-13 00:03:52 +02:00
parent 26d14f7cb0
commit e03edad824
77 changed files with 1210 additions and 270 deletions
+7
View File
@@ -49,6 +49,13 @@ jobs:
- name: Verify translation hygiene
run: python3 scripts/clean_translations.py --check --strict
- name: Verify workflow and script guards
run: |
python3 scripts/check_build_workflow.py
python3 scripts/check_update_packages_workflow.py
python3 scripts/test_pubspec_version.py
python3 scripts/test_clean_translations.py
- name: Verify formatting
run: |
paths=(lib)
+2
View File
@@ -18,6 +18,8 @@ linter:
avoid_print: true
dart_code_linter:
rules-exclude:
- "test/**"
metrics-exclude:
- "test/**"
@@ -23,13 +23,20 @@ internal object MediaCodecQuery {
return null
}
fun isHardwareAccelerated(info: MediaCodecInfo): Boolean {
// API 29 added the manufacturer-provided classification. Older releases
// expose only component names, so retain the legacy software-name fallback.
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
fun isHardwareAccelerated(info: MediaCodecInfo): Boolean = isHardwareAccelerated(Build.VERSION.SDK_INT, info.isHardwareAccelerated, info.name)
internal fun isHardwareAccelerated(
sdkInt: Int,
platformReportsHardware: Boolean,
name: String
): Boolean {
// API 29 added manufacturer-provided classification, but some Codec2
// builders still flag known software components as hardware. Require both
// signals there; older releases expose only component names.
return if (sdkInt >= Build.VERSION_CODES.Q) {
platformReportsHardware && !isSoftwareCodecName(name)
} else {
!isSoftwareCodecName(info.name)
!isSoftwareCodecName(name)
}
}
@@ -31,4 +31,17 @@ class MediaCodecQueryTest {
assertFalse("expected hardware codec: $name", MediaCodecQuery.isSoftwareCodecName(name))
}
}
@Test
fun api29RequiresPlatformHardwareFlagAndNonSoftwareComponentName() {
assertFalse(MediaCodecQuery.isHardwareAccelerated(29, true, "c2.ffmpeg.aac.decoder"))
assertFalse(MediaCodecQuery.isHardwareAccelerated(29, false, "c2.qti.avc.decoder"))
assertTrue(MediaCodecQuery.isHardwareAccelerated(29, true, "c2.qti.avc.decoder"))
}
@Test
fun preApi29RetainsNameBasedFallback() {
assertFalse(MediaCodecQuery.isHardwareAccelerated(28, true, "OMX.google.h264.decoder"))
assertTrue(MediaCodecQuery.isHardwareAccelerated(28, false, "OMX.qcom.video.decoder.avc"))
}
}
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Премахнато от продължаване на гледането",
"errorLoading": "Грешка: ${error}",
"streamInterrupted": "Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.",
"liveStreamInterrupted": "Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.",
"fileInfoNotAvailable": "Информацията за файла не е налична",
"errorLoadingFileInfo": "Грешка при зареждане на информация за файла: ${error}",
"errorLoadingSeries": "Грешка при зареждане на сериала",
@@ -916,6 +917,7 @@
"watchChannel": "Гледай канал",
"favorites": "Любими",
"reorderFavorites": "Пренареди любимите",
"favoritesLoadFailed": "Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.",
"joinSession": "Присъедини се към текуща сесия",
"watchFromStart": "Гледай от началото (преди ${minutes} мин)",
"watchLive": "Гледай на живо",
@@ -1140,6 +1142,7 @@
"customAmount": "Персонален брой...",
"includeSpecials": "Включи специалните",
"howManyEpisodes": "Колко епизода?",
"invalidEpisodeCount": "Въведете валиден брой епизоди.",
"keepSynced": "Поддържай синхронизирано",
"downloadOnce": "Изтегли еднократно",
"keepNUnwatched": "Пази ${count} негледани",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Fjernet fra Fortsæt med at se",
"errorLoading": "Fejl: ${error}",
"streamInterrupted": "Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.",
"liveStreamInterrupted": "Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.",
"fileInfoNotAvailable": "Filinfo ikke tilgængelig",
"errorLoadingFileInfo": "Fejl ved indlæsning af filinfo: ${error}",
"errorLoadingSeries": "Fejl ved indlæsning af serie",
@@ -916,6 +917,7 @@
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Omarranger favoritter",
"favoritesLoadFailed": "Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.",
"joinSession": "Deltag i igangværende session",
"watchFromStart": "Se fra start (${minutes} min siden)",
"watchLive": "Se live",
@@ -1140,6 +1142,7 @@
"customAmount": "Angiv antal...",
"includeSpecials": "Inkludér specials",
"howManyEpisodes": "Hvor mange episoder?",
"invalidEpisodeCount": "Indtast et gyldigt antal episoder.",
"keepSynced": "Hold synkroniseret",
"downloadOnce": "Download én gang",
"keepNUnwatched": "Behold ${count} usete",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Aus Weiterschauen' entfernt",
"errorLoading": "Fehler: ${error}",
"streamInterrupted": "Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.",
"liveStreamInterrupted": "Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.",
"fileInfoNotAvailable": "Dateiinfo nicht verfügbar",
"errorLoadingFileInfo": "Fehler beim Laden der Dateiinfo: ${error}",
"errorLoadingSeries": "Fehler beim Laden der Serie",
@@ -916,6 +917,7 @@
"watchChannel": "Kanal ansehen",
"favorites": "Favoriten",
"reorderFavorites": "Favoriten sortieren",
"favoritesLoadFailed": "Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.",
"joinSession": "Laufender Sitzung beitreten",
"watchFromStart": "Von Anfang an ansehen (vor ${minutes} Min.)",
"watchLive": "Live ansehen",
@@ -1140,6 +1142,7 @@
"customAmount": "Eigene Anzahl...",
"includeSpecials": "Specials einschließen",
"howManyEpisodes": "Wie viele Episoden?",
"invalidEpisodeCount": "Gib eine gültige Episodenanzahl ein.",
"keepSynced": "Synchronisiert halten",
"downloadOnce": "Einmal herunterladen",
"keepNUnwatched": "${count} ungesehene behalten",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Removed from Continue Watching",
"errorLoading": "Error: ${error}",
"streamInterrupted": "The stream was interrupted. Press play or seek to retry.",
"liveStreamInterrupted": "The live stream was interrupted. Press play to retry.",
"fileInfoNotAvailable": "File information not available",
"errorLoadingFileInfo": "Error loading file info: ${error}",
"errorLoadingSeries": "Error loading series",
@@ -916,6 +917,7 @@
"watchChannel": "Watch Channel",
"favorites": "Favorites",
"reorderFavorites": "Reorder Favorites",
"favoritesLoadFailed": "Could not load favorites. Check your connection and try again.",
"joinSession": "Join Session in Progress",
"watchFromStart": "Watch from start (${minutes} min ago)",
"watchLive": "Watch Live",
@@ -1140,6 +1142,7 @@
"customAmount": "Custom amount...",
"includeSpecials": "Include Specials",
"howManyEpisodes": "How many episodes?",
"invalidEpisodeCount": "Enter a valid episode count.",
"keepSynced": "Keep synced",
"downloadOnce": "Download once",
"keepNUnwatched": "Keep ${count} unwatched",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Eliminado de Seguir Viendo",
"errorLoading": "Error: ${error}",
"streamInterrupted": "La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.",
"liveStreamInterrupted": "La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.",
"fileInfoNotAvailable": "Información de archivo no disponible",
"errorLoadingFileInfo": "Error al cargar info de archivo: ${error}",
"errorLoadingSeries": "Error al cargar la serie",
@@ -916,6 +917,7 @@
"watchChannel": "Ver canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"favoritesLoadFailed": "No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.",
"joinSession": "Unirse a sesión en curso",
"watchFromStart": "Ver desde el inicio (hace ${minutes} min)",
"watchLive": "Ver en vivo",
@@ -1140,6 +1142,7 @@
"customAmount": "Cantidad personalizada...",
"includeSpecials": "Incluir especiales",
"howManyEpisodes": "¿Cuántos episodios?",
"invalidEpisodeCount": "Introduce un número de episodios válido.",
"keepSynced": "Mantener sincronizado",
"downloadOnce": "Descargar una vez",
"keepNUnwatched": "Mantener ${count} sin ver",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Supprimer de \"Continuer à regarder\"",
"errorLoading": "Erreur: ${error}",
"streamInterrupted": "La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.",
"liveStreamInterrupted": "Le direct a été interrompu. Appuyez sur Lecture pour réessayer.",
"fileInfoNotAvailable": "Informations sur le fichier non disponibles",
"errorLoadingFileInfo": "Erreur lors du chargement des informations sur le fichier: ${error}",
"errorLoadingSeries": "Erreur lors du chargement de la série",
@@ -916,6 +917,7 @@
"watchChannel": "Regarder la chaîne",
"favorites": "Favoris",
"reorderFavorites": "Réorganiser les favoris",
"favoritesLoadFailed": "Impossible de charger les favoris. Vérifiez votre connexion et réessayez.",
"joinSession": "Rejoindre la session en cours",
"watchFromStart": "Regarder depuis le début (il y a ${minutes} min)",
"watchLive": "Regarder en direct",
@@ -1140,6 +1142,7 @@
"customAmount": "Quantité personnalisée...",
"includeSpecials": "Inclure les spéciaux",
"howManyEpisodes": "Combien d'épisodes ?",
"invalidEpisodeCount": "Saisissez un nombre d'épisodes valide.",
"keepSynced": "Garder synchronisé",
"downloadOnce": "Télécharger une fois",
"keepNUnwatched": "Garder ${count} non vus",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Rimosso da Continua a guardare",
"errorLoading": "Errore: ${error}",
"streamInterrupted": "La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.",
"liveStreamInterrupted": "La diretta si è interrotta. Premi Riproduci per riprovare.",
"fileInfoNotAvailable": "Informazioni sul file non disponibili",
"errorLoadingFileInfo": "Errore caricamento informazioni sul file: ${error}",
"errorLoadingSeries": "Errore caricamento serie",
@@ -916,6 +917,7 @@
"watchChannel": "Guarda canale",
"favorites": "Preferiti",
"reorderFavorites": "Riordina preferiti",
"favoritesLoadFailed": "Impossibile caricare i preferiti. Controlla la connessione e riprova.",
"joinSession": "Partecipa alla sessione in corso",
"watchFromStart": "Guarda dall'inizio (${minutes} min fa)",
"watchLive": "Guarda in diretta",
@@ -1140,6 +1142,7 @@
"customAmount": "Quantità personalizzata...",
"includeSpecials": "Includi gli speciali",
"howManyEpisodes": "Quanti episodi?",
"invalidEpisodeCount": "Inserisci un numero di episodi valido.",
"keepSynced": "Mantieni sincronizzato",
"downloadOnce": "Scarica una volta",
"keepNUnwatched": "Mantieni ${count} non visti",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "視聴中から削除しました",
"errorLoading": "エラー: ${error}",
"streamInterrupted": "ストリームが中断されました。再生を押すかシークして再試行してください。",
"liveStreamInterrupted": "ライブストリームが中断されました。再生を押して再試行してください。",
"fileInfoNotAvailable": "ファイル情報が利用できません",
"errorLoadingFileInfo": "ファイル情報の読み込みエラー: ${error}",
"errorLoadingSeries": "シリーズの読み込みエラー",
@@ -915,6 +916,7 @@
"watchChannel": "チャンネルを視聴",
"favorites": "お気に入り",
"reorderFavorites": "お気に入りを並べ替え",
"favoritesLoadFailed": "お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。",
"joinSession": "進行中のセッションに参加",
"watchFromStart": "最初から視聴(${minutes}分前に開始)",
"watchLive": "ライブで視聴",
@@ -1138,6 +1140,7 @@
"customAmount": "数を指定...",
"includeSpecials": "スペシャルを含める",
"howManyEpisodes": "何エピソード?",
"invalidEpisodeCount": "有効なエピソード数を入力してください。",
"keepSynced": "同期を維持",
"downloadOnce": "一度だけダウンロード",
"keepNUnwatched": "未視聴を${count}件保持",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "계속 시청 목록에서 제거됨",
"errorLoading": "오류: ${error}",
"streamInterrupted": "스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.",
"liveStreamInterrupted": "라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.",
"fileInfoNotAvailable": "파일 정보가 없습니다",
"errorLoadingFileInfo": "파일 정보 로딩 중 오류: ${error}",
"errorLoadingSeries": "시리즈 로딩 중 오류",
@@ -915,6 +916,7 @@
"watchChannel": "채널 시청",
"favorites": "즐겨찾기",
"reorderFavorites": "즐겨찾기 순서 변경",
"favoritesLoadFailed": "즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.",
"joinSession": "진행 중인 세션 참여",
"watchFromStart": "처음부터 시청 (${minutes}분 전 시작)",
"watchLive": "실시간 시청",
@@ -1138,6 +1140,7 @@
"customAmount": "직접 입력...",
"includeSpecials": "스페셜 포함",
"howManyEpisodes": "몇 개의 에피소드?",
"invalidEpisodeCount": "올바른 에피소드 수를 입력하세요.",
"keepSynced": "동기화 유지",
"downloadOnce": "한 번만 다운로드",
"keepNUnwatched": "미시청 ${count}개 유지",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Fjernet fra Fortsett å se",
"errorLoading": "Feil: ${error}",
"streamInterrupted": "Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.",
"liveStreamInterrupted": "Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.",
"fileInfoNotAvailable": "Filinformasjon ikke tilgjengelig",
"errorLoadingFileInfo": "Feil ved lasting av filinformasjon: ${error}",
"errorLoadingSeries": "Feil ved lasting av serie",
@@ -916,6 +917,7 @@
"watchChannel": "Se kanal",
"favorites": "Favoritter",
"reorderFavorites": "Endre rekkefølge på favoritter",
"favoritesLoadFailed": "Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.",
"joinSession": "Bli med i pågående økt",
"watchFromStart": "Se fra starten (${minutes} min siden)",
"watchLive": "Se direkte",
@@ -1140,6 +1142,7 @@
"customAmount": "Egendefinert antall...",
"includeSpecials": "Inkluder spesialepisoder",
"howManyEpisodes": "Hvor mange episoder?",
"invalidEpisodeCount": "Angi et gyldig antall episoder.",
"keepSynced": "Hold synkronisert",
"downloadOnce": "Last ned én gang",
"keepNUnwatched": "Behold ${count} usette",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Verwijderd uit Doorgaan met kijken",
"errorLoading": "Fout: ${error}",
"streamInterrupted": "De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.",
"liveStreamInterrupted": "De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.",
"fileInfoNotAvailable": "Bestand informatie niet beschikbaar",
"errorLoadingFileInfo": "Fout bij laden bestand info: ${error}",
"errorLoadingSeries": "Fout bij laden serie",
@@ -916,6 +917,7 @@
"watchChannel": "Kanaal bekijken",
"favorites": "Favorieten",
"reorderFavorites": "Favorieten herordenen",
"favoritesLoadFailed": "Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.",
"joinSession": "Deelnemen aan lopende sessie",
"watchFromStart": "Kijk vanaf het begin (${minutes} min geleden)",
"watchLive": "Live kijken",
@@ -1140,6 +1142,7 @@
"customAmount": "Aangepast aantal...",
"includeSpecials": "Specials opnemen",
"howManyEpisodes": "Hoeveel afleveringen?",
"invalidEpisodeCount": "Voer een geldig aantal afleveringen in.",
"keepSynced": "Gesynchroniseerd houden",
"downloadOnce": "Eenmalig downloaden",
"keepNUnwatched": "${count} onbekeken behouden",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Usunięto z kontynuowania oglądania",
"errorLoading": "Błąd: ${error}",
"streamInterrupted": "Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.",
"liveStreamInterrupted": "Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.",
"fileInfoNotAvailable": "Informacje o pliku niedostępne",
"errorLoadingFileInfo": "Błąd ładowania informacji o pliku: ${error}",
"errorLoadingSeries": "Błąd ładowania serialu",
@@ -918,6 +919,7 @@
"watchChannel": "Oglądaj kanał",
"favorites": "Ulubione",
"reorderFavorites": "Zmień kolejność ulubionych",
"favoritesLoadFailed": "Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.",
"joinSession": "Dołącz do trwającej sesji",
"watchFromStart": "Oglądaj od początku (${minutes} min temu)",
"watchLive": "Oglądaj na żywo",
@@ -1144,6 +1146,7 @@
"customAmount": "Własna ilość...",
"includeSpecials": "Uwzględnij odcinki specjalne",
"howManyEpisodes": "Ile odcinków?",
"invalidEpisodeCount": "Wprowadź prawidłową liczbę odcinków.",
"keepSynced": "Synchronizuj na bieżąco",
"downloadOnce": "Pobierz raz",
"keepNUnwatched": "Zachowaj ${count} nieobejrzanych",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Removido de Continuar Assistindo",
"errorLoading": "Erro: ${error}",
"streamInterrupted": "A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.",
"liveStreamInterrupted": "A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.",
"fileInfoNotAvailable": "Informações do arquivo não disponíveis",
"errorLoadingFileInfo": "Erro ao carregar info do arquivo: ${error}",
"errorLoadingSeries": "Erro ao carregar série",
@@ -916,6 +917,7 @@
"watchChannel": "Assistir Canal",
"favorites": "Favoritos",
"reorderFavorites": "Reordenar favoritos",
"favoritesLoadFailed": "Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.",
"joinSession": "Entrar na sessão em andamento",
"watchFromStart": "Assistir do início (${minutes} min atrás)",
"watchLive": "Assistir ao vivo",
@@ -1140,6 +1142,7 @@
"customAmount": "Quantidade personalizada...",
"includeSpecials": "Incluir especiais",
"howManyEpisodes": "Quantos episódios?",
"invalidEpisodeCount": "Insira uma quantidade válida de episódios.",
"keepSynced": "Manter sincronizado",
"downloadOnce": "Baixar uma vez",
"keepNUnwatched": "Manter ${count} não assistidos",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Удалено из «Продолжить просмотр»",
"errorLoading": "Ошибка: ${error}",
"streamInterrupted": "Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.",
"liveStreamInterrupted": "Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.",
"fileInfoNotAvailable": "Информация о файле недоступна",
"errorLoadingFileInfo": "Ошибка загрузки информации о файле: ${error}",
"errorLoadingSeries": "Ошибка загрузки сериала",
@@ -918,6 +919,7 @@
"watchChannel": "Смотреть канал",
"favorites": "Избранное",
"reorderFavorites": "Изменить порядок избранного",
"favoritesLoadFailed": "Не удалось загрузить избранное. Проверьте подключение и повторите попытку.",
"joinSession": "Присоединиться к текущему сеансу",
"watchFromStart": "Смотреть сначала (${minutes} мин. назад)",
"watchLive": "Смотреть в прямом эфире",
@@ -1144,6 +1146,7 @@
"customAmount": "Указать количество...",
"includeSpecials": "Включить спецвыпуски",
"howManyEpisodes": "Сколько эпизодов?",
"invalidEpisodeCount": "Введите допустимое количество эпизодов.",
"keepSynced": "Синхронизировать",
"downloadOnce": "Скачать один раз",
"keepNUnwatched": "Хранить ${count} непросмотренных",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 16
/// Strings: 22466 (1404 per locale)
/// Strings: 22514 (1407 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesBg extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Премахнато от продължаване на гледането';
@override String errorLoading({required Object error}) => 'Грешка: ${error}';
@override String get streamInterrupted => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.';
@override String get liveStreamInterrupted => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.';
@override String get fileInfoNotAvailable => 'Информацията за файла не е налична';
@override String errorLoadingFileInfo({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}';
@override String get errorLoadingSeries => 'Грешка при зареждане на сериала';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvBg extends TranslationsLiveTvEn {
@override String get watchChannel => 'Гледай канал';
@override String get favorites => 'Любими';
@override String get reorderFavorites => 'Пренареди любимите';
@override String get favoritesLoadFailed => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.';
@override String get joinSession => 'Присъедини се към текуща сесия';
@override String watchFromStart({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)';
@override String get watchLive => 'Гледай на живо';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsBg extends TranslationsDownloadsEn {
@override String get customAmount => 'Персонален брой...';
@override String get includeSpecials => 'Включи специалните';
@override String get howManyEpisodes => 'Колко епизода?';
@override String get invalidEpisodeCount => 'Въведете валиден брой епизоди.';
@override String get keepSynced => 'Поддържай синхронизирано';
@override String get downloadOnce => 'Изтегли еднократно';
@override String keepNUnwatched({required Object count}) => 'Пази ${count} негледани';
@@ -2601,6 +2604,7 @@ extension on TranslationsBg {
'messages.removedFromContinueWatching' => 'Премахнато от продължаване на гледането',
'messages.errorLoading' => ({required Object error}) => 'Грешка: ${error}',
'messages.streamInterrupted' => 'Потокът прекъсна. Натиснете „Пусни“ или превъртете, за да опитате отново.',
'messages.liveStreamInterrupted' => 'Потокът на живо прекъсна. Натиснете „Пусни“, за да опитате отново.',
'messages.fileInfoNotAvailable' => 'Информацията за файла не е налична',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Грешка при зареждане на информация за файла: ${error}',
'messages.errorLoadingSeries' => 'Грешка при зареждане на сериала',
@@ -2608,9 +2612,9 @@ extension on TranslationsBg {
'messages.noDescriptionAvailable' => 'Няма налично описание',
'messages.noProfilesAvailable' => 'Няма налични профили',
'messages.contactAdminForProfiles' => 'Свържете се с администратора на сървъра, за да добави профили',
'messages.unableToDetermineLibrarySection' => 'Не може да се определи секцията на библиотеката за този елемент',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Не може да се определи секцията на библиотеката за този елемент',
'messages.logsCleared' => 'Логовете са изчистени',
'messages.logsCopied' => 'Логовете са копирани в клипборда',
'messages.noLogsAvailable' => 'Няма налични логове',
@@ -2935,6 +2939,7 @@ extension on TranslationsBg {
'liveTv.watchChannel' => 'Гледай канал',
'liveTv.favorites' => 'Любими',
'liveTv.reorderFavorites' => 'Пренареди любимите',
'liveTv.favoritesLoadFailed' => 'Любимите не можаха да се заредят. Проверете връзката си и опитайте отново.',
'liveTv.joinSession' => 'Присъедини се към текуща сесия',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Гледай от началото (преди ${minutes} мин)',
'liveTv.watchLive' => 'Гледай на живо',
@@ -3121,10 +3126,10 @@ extension on TranslationsBg {
'downloads.episodesQueued' => ({required Object count}) => '${count} епизода са добавени в опашката за изтегляне',
'downloads.downloadDeleted' => 'Изтеглянето е изтрито',
'downloads.deleteConfirm' => ({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?',
'downloads.cancelledDownloadTitle' => 'Отменено изтегляне',
'downloads.cancelledDownloadMessage' => 'Това изтегляне беше отменено. Какво искате да направите?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Отменено изтегляне',
'downloads.cancelledDownloadMessage' => 'Това изтегляне беше отменено. Какво искате да направите?',
'downloads.allEpisodesAlreadyDownloaded' => 'Всички епизоди вече са изтеглени',
'downloads.resumeDownload' => 'Възобнови изтеглянето',
'downloads.cancelledDownload' => 'Отменено изтегляне',
@@ -3148,6 +3153,7 @@ extension on TranslationsBg {
'downloads.customAmount' => 'Персонален брой...',
'downloads.includeSpecials' => 'Включи специалните',
'downloads.howManyEpisodes' => 'Колко епизода?',
'downloads.invalidEpisodeCount' => 'Въведете валиден брой епизоди.',
'downloads.keepSynced' => 'Поддържай синхронизирано',
'downloads.downloadOnce' => 'Изтегли еднократно',
'downloads.keepNUnwatched' => ({required Object count}) => 'Пази ${count} негледани',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesDa extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Fjernet fra Fortsæt med at se';
@override String errorLoading({required Object error}) => 'Fejl: ${error}';
@override String get streamInterrupted => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.';
@override String get liveStreamInterrupted => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.';
@override String get fileInfoNotAvailable => 'Filinfo ikke tilgængelig';
@override String errorLoadingFileInfo({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}';
@override String get errorLoadingSeries => 'Fejl ved indlæsning af serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvDa extends TranslationsLiveTvEn {
@override String get watchChannel => 'Se kanal';
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Omarranger favoritter';
@override String get favoritesLoadFailed => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.';
@override String get joinSession => 'Deltag i igangværende session';
@override String watchFromStart({required Object minutes}) => 'Se fra start (${minutes} min siden)';
@override String get watchLive => 'Se live';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsDa extends TranslationsDownloadsEn {
@override String get customAmount => 'Angiv antal...';
@override String get includeSpecials => 'Inkludér specials';
@override String get howManyEpisodes => 'Hvor mange episoder?';
@override String get invalidEpisodeCount => 'Indtast et gyldigt antal episoder.';
@override String get keepSynced => 'Hold synkroniseret';
@override String get downloadOnce => 'Download én gang';
@override String keepNUnwatched({required Object count}) => 'Behold ${count} usete';
@@ -2601,6 +2604,7 @@ extension on TranslationsDa {
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsæt med at se',
'messages.errorLoading' => ({required Object error}) => 'Fejl: ${error}',
'messages.streamInterrupted' => 'Streamen blev afbrudt. Tryk på afspil, eller spol for at prøve igen.',
'messages.liveStreamInterrupted' => 'Livestreamen blev afbrudt. Tryk på afspil for at prøve igen.',
'messages.fileInfoNotAvailable' => 'Filinfo ikke tilgængelig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fejl ved indlæsning af filinfo: ${error}',
'messages.errorLoadingSeries' => 'Fejl ved indlæsning af serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsDa {
'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgængelig',
'messages.noProfilesAvailable' => 'Ingen profiler tilgængelige',
'messages.contactAdminForProfiles' => 'Kontakt din serveradministrator for at tilføje profiler',
'messages.unableToDetermineLibrarySection' => 'Kan ikke bestemme biblioteksafdeling for dette element',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Kan ikke bestemme biblioteksafdeling for dette element',
'messages.logsCleared' => 'Logs ryddet',
'messages.logsCopied' => 'Logs kopieret til udklipsholder',
'messages.noLogsAvailable' => 'Ingen logs tilgængelige',
@@ -2935,6 +2939,7 @@ extension on TranslationsDa {
'liveTv.watchChannel' => 'Se kanal',
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Omarranger favoritter',
'liveTv.favoritesLoadFailed' => 'Favoritter kunne ikke indlæses. Kontrollér forbindelsen, og prøv igen.',
'liveTv.joinSession' => 'Deltag i igangværende session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra start (${minutes} min siden)',
'liveTv.watchLive' => 'Se live',
@@ -3121,10 +3126,10 @@ extension on TranslationsDa {
'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i downloadkø',
'downloads.downloadDeleted' => 'Download slettet',
'downloads.deleteConfirm' => ({required Object title}) => 'Slet "${title}" fra denne enhed?',
'downloads.cancelledDownloadTitle' => 'Annulleret download',
'downloads.cancelledDownloadMessage' => 'Denne download blev annulleret. Hvad vil du gøre?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Annulleret download',
'downloads.cancelledDownloadMessage' => 'Denne download blev annulleret. Hvad vil du gøre?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle episoder er allerede downloadet',
'downloads.resumeDownload' => 'Genoptag download',
'downloads.cancelledDownload' => 'Annulleret download',
@@ -3148,6 +3153,7 @@ extension on TranslationsDa {
'downloads.customAmount' => 'Angiv antal...',
'downloads.includeSpecials' => 'Inkludér specials',
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
'downloads.invalidEpisodeCount' => 'Indtast et gyldigt antal episoder.',
'downloads.keepSynced' => 'Hold synkroniseret',
'downloads.downloadOnce' => 'Download én gang',
'downloads.keepNUnwatched' => ({required Object count}) => 'Behold ${count} usete',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesDe extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Aus Weiterschauen\' entfernt';
@override String errorLoading({required Object error}) => 'Fehler: ${error}';
@override String get streamInterrupted => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.';
@override String get liveStreamInterrupted => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.';
@override String get fileInfoNotAvailable => 'Dateiinfo nicht verfügbar';
@override String errorLoadingFileInfo({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}';
@override String get errorLoadingSeries => 'Fehler beim Laden der Serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvDe extends TranslationsLiveTvEn {
@override String get watchChannel => 'Kanal ansehen';
@override String get favorites => 'Favoriten';
@override String get reorderFavorites => 'Favoriten sortieren';
@override String get favoritesLoadFailed => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.';
@override String get joinSession => 'Laufender Sitzung beitreten';
@override String watchFromStart({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)';
@override String get watchLive => 'Live ansehen';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsDe extends TranslationsDownloadsEn {
@override String get customAmount => 'Eigene Anzahl...';
@override String get includeSpecials => 'Specials einschließen';
@override String get howManyEpisodes => 'Wie viele Episoden?';
@override String get invalidEpisodeCount => 'Gib eine gültige Episodenanzahl ein.';
@override String get keepSynced => 'Synchronisiert halten';
@override String get downloadOnce => 'Einmal herunterladen';
@override String keepNUnwatched({required Object count}) => '${count} ungesehene behalten';
@@ -2601,6 +2604,7 @@ extension on TranslationsDe {
'messages.removedFromContinueWatching' => 'Aus Weiterschauen\' entfernt',
'messages.errorLoading' => ({required Object error}) => 'Fehler: ${error}',
'messages.streamInterrupted' => 'Der Stream wurde unterbrochen. Drücke auf Wiedergabe oder spule, um es erneut zu versuchen.',
'messages.liveStreamInterrupted' => 'Der Livestream wurde unterbrochen. Drücke auf Wiedergabe, um es erneut zu versuchen.',
'messages.fileInfoNotAvailable' => 'Dateiinfo nicht verfügbar',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fehler beim Laden der Dateiinfo: ${error}',
'messages.errorLoadingSeries' => 'Fehler beim Laden der Serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsDe {
'messages.noDescriptionAvailable' => 'Keine Beschreibung verfügbar',
'messages.noProfilesAvailable' => 'Keine Profile verfügbar',
'messages.contactAdminForProfiles' => 'Kontaktiere deinen Serveradministrator, um Profile hinzuzufügen',
'messages.unableToDetermineLibrarySection' => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Bibliotheksbereich für dieses Element kann nicht ermittelt werden',
'messages.logsCleared' => 'Protokolle gelöscht',
'messages.logsCopied' => 'Protokolle in Zwischenablage kopiert',
'messages.noLogsAvailable' => 'Keine Protokolle verfügbar',
@@ -2935,6 +2939,7 @@ extension on TranslationsDe {
'liveTv.watchChannel' => 'Kanal ansehen',
'liveTv.favorites' => 'Favoriten',
'liveTv.reorderFavorites' => 'Favoriten sortieren',
'liveTv.favoritesLoadFailed' => 'Favoriten konnten nicht geladen werden. Überprüfe deine Verbindung und versuche es erneut.',
'liveTv.joinSession' => 'Laufender Sitzung beitreten',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Von Anfang an ansehen (vor ${minutes} Min.)',
'liveTv.watchLive' => 'Live ansehen',
@@ -3121,10 +3126,10 @@ extension on TranslationsDe {
'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt',
'downloads.downloadDeleted' => 'Download gelöscht',
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" von diesem Gerät löschen?',
'downloads.cancelledDownloadTitle' => 'Abgebrochener Download',
'downloads.cancelledDownloadMessage' => 'Dieser Download wurde abgebrochen. Was möchtest du tun?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Abgebrochener Download',
'downloads.cancelledDownloadMessage' => 'Dieser Download wurde abgebrochen. Was möchtest du tun?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle Episoden sind bereits heruntergeladen',
'downloads.resumeDownload' => 'Download fortsetzen',
'downloads.cancelledDownload' => 'Abgebrochener Download',
@@ -3148,6 +3153,7 @@ extension on TranslationsDe {
'downloads.customAmount' => 'Eigene Anzahl...',
'downloads.includeSpecials' => 'Specials einschließen',
'downloads.howManyEpisodes' => 'Wie viele Episoden?',
'downloads.invalidEpisodeCount' => 'Gib eine gültige Episodenanzahl ein.',
'downloads.keepSynced' => 'Synchronisiert halten',
'downloads.downloadOnce' => 'Einmal herunterladen',
'downloads.keepNUnwatched' => ({required Object count}) => '${count} ungesehene behalten',
+15 -3
View File
@@ -1644,6 +1644,9 @@ class TranslationsMessagesEn {
/// en: 'The stream was interrupted. Press play or seek to retry.'
String get streamInterrupted => 'The stream was interrupted. Press play or seek to retry.';
/// en: 'The live stream was interrupted. Press play to retry.'
String get liveStreamInterrupted => 'The live stream was interrupted. Press play to retry.';
/// en: 'File information not available'
String get fileInfoNotAvailable => 'File information not available';
@@ -2624,6 +2627,9 @@ class TranslationsLiveTvEn {
/// en: 'Reorder Favorites'
String get reorderFavorites => 'Reorder Favorites';
/// en: 'Could not load favorites. Check your connection and try again.'
String get favoritesLoadFailed => 'Could not load favorites. Check your connection and try again.';
/// en: 'Join Session in Progress'
String get joinSession => 'Join Session in Progress';
@@ -3305,6 +3311,9 @@ class TranslationsDownloadsEn {
/// en: 'How many episodes?'
String get howManyEpisodes => 'How many episodes?';
/// en: 'Enter a valid episode count.'
String get invalidEpisodeCount => 'Enter a valid episode count.';
/// en: 'Keep synced'
String get keepSynced => 'Keep synced';
@@ -5412,6 +5421,7 @@ extension on Translations {
'messages.removedFromContinueWatching' => 'Removed from Continue Watching',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'The stream was interrupted. Press play or seek to retry.',
'messages.liveStreamInterrupted' => 'The live stream was interrupted. Press play to retry.',
'messages.fileInfoNotAvailable' => 'File information not available',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error loading file info: ${error}',
'messages.errorLoadingSeries' => 'Error loading series',
@@ -5419,9 +5429,9 @@ extension on Translations {
'messages.noDescriptionAvailable' => 'No description available',
'messages.noProfilesAvailable' => 'No profiles available',
'messages.contactAdminForProfiles' => 'Contact your server administrator to add profiles',
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item',
'messages.logsCleared' => 'Logs cleared',
'messages.logsCopied' => 'Logs copied to clipboard',
'messages.noLogsAvailable' => 'No logs available',
@@ -5746,6 +5756,7 @@ extension on Translations {
'liveTv.watchChannel' => 'Watch Channel',
'liveTv.favorites' => 'Favorites',
'liveTv.reorderFavorites' => 'Reorder Favorites',
'liveTv.favoritesLoadFailed' => 'Could not load favorites. Check your connection and try again.',
'liveTv.joinSession' => 'Join Session in Progress',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Watch from start (${minutes} min ago)',
'liveTv.watchLive' => 'Watch Live',
@@ -5932,10 +5943,10 @@ extension on Translations {
'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download',
'downloads.downloadDeleted' => 'Download deleted',
'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?',
'downloads.cancelledDownloadTitle' => 'Cancelled Download',
'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Cancelled Download',
'downloads.cancelledDownloadMessage' => 'This download was cancelled. What would you like to do?',
'downloads.allEpisodesAlreadyDownloaded' => 'All episodes already downloaded',
'downloads.resumeDownload' => 'Resume download',
'downloads.cancelledDownload' => 'Cancelled download',
@@ -5959,6 +5970,7 @@ extension on Translations {
'downloads.customAmount' => 'Custom amount...',
'downloads.includeSpecials' => 'Include Specials',
'downloads.howManyEpisodes' => 'How many episodes?',
'downloads.invalidEpisodeCount' => 'Enter a valid episode count.',
'downloads.keepSynced' => 'Keep synced',
'downloads.downloadOnce' => 'Download once',
'downloads.keepNUnwatched' => ({required Object count}) => 'Keep ${count} unwatched',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesEs extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Eliminado de Seguir Viendo';
@override String errorLoading({required Object error}) => 'Error: ${error}';
@override String get streamInterrupted => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.';
@override String get liveStreamInterrupted => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.';
@override String get fileInfoNotAvailable => 'Información de archivo no disponible';
@override String errorLoadingFileInfo({required Object error}) => 'Error al cargar info de archivo: ${error}';
@override String get errorLoadingSeries => 'Error al cargar la serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvEs extends TranslationsLiveTvEn {
@override String get watchChannel => 'Ver canal';
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get favoritesLoadFailed => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.';
@override String get joinSession => 'Unirse a sesión en curso';
@override String watchFromStart({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)';
@override String get watchLive => 'Ver en vivo';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsEs extends TranslationsDownloadsEn {
@override String get customAmount => 'Cantidad personalizada...';
@override String get includeSpecials => 'Incluir especiales';
@override String get howManyEpisodes => '¿Cuántos episodios?';
@override String get invalidEpisodeCount => 'Introduce un número de episodios válido.';
@override String get keepSynced => 'Mantener sincronizado';
@override String get downloadOnce => 'Descargar una vez';
@override String keepNUnwatched({required Object count}) => 'Mantener ${count} sin ver';
@@ -2601,6 +2604,7 @@ extension on TranslationsEs {
'messages.removedFromContinueWatching' => 'Eliminado de Seguir Viendo',
'messages.errorLoading' => ({required Object error}) => 'Error: ${error}',
'messages.streamInterrupted' => 'La reproducción se interrumpió. Pulsa reproducir o avanza para volver a intentarlo.',
'messages.liveStreamInterrupted' => 'La transmisión en vivo se interrumpió. Pulsa reproducir para volver a intentarlo.',
'messages.fileInfoNotAvailable' => 'Información de archivo no disponible',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Error al cargar info de archivo: ${error}',
'messages.errorLoadingSeries' => 'Error al cargar la serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsEs {
'messages.noDescriptionAvailable' => 'No hay descripción disponible',
'messages.noProfilesAvailable' => 'No hay perfiles disponibles',
'messages.contactAdminForProfiles' => 'Contacta a tu administrador del servidor para añadir perfiles',
'messages.unableToDetermineLibrarySection' => 'No se puede determinar la sección de biblioteca para este elemento',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'No se puede determinar la sección de biblioteca para este elemento',
'messages.logsCleared' => 'Logs borrados',
'messages.logsCopied' => 'Logs copiados al portapapeles',
'messages.noLogsAvailable' => 'No hay logs disponibles',
@@ -2935,6 +2939,7 @@ extension on TranslationsEs {
'liveTv.watchChannel' => 'Ver canal',
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.favoritesLoadFailed' => 'No se pudieron cargar los favoritos. Comprueba tu conexión e inténtalo de nuevo.',
'liveTv.joinSession' => 'Unirse a sesión en curso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Ver desde el inicio (hace ${minutes} min)',
'liveTv.watchLive' => 'Ver en vivo',
@@ -3121,10 +3126,10 @@ extension on TranslationsEs {
'downloads.episodesQueued' => ({required Object count}) => '${count} episodios en cola para descargar',
'downloads.downloadDeleted' => 'Descarga eliminada',
'downloads.deleteConfirm' => ({required Object title}) => '¿Eliminar "${title}" de este dispositivo?',
'downloads.cancelledDownloadTitle' => 'Descarga cancelada',
'downloads.cancelledDownloadMessage' => 'Esta descarga se canceló. ¿Qué quieres hacer?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Descarga cancelada',
'downloads.cancelledDownloadMessage' => 'Esta descarga se canceló. ¿Qué quieres hacer?',
'downloads.allEpisodesAlreadyDownloaded' => 'Todos los episodios ya están descargados',
'downloads.resumeDownload' => 'Reanudar descarga',
'downloads.cancelledDownload' => 'Descarga cancelada',
@@ -3148,6 +3153,7 @@ extension on TranslationsEs {
'downloads.customAmount' => 'Cantidad personalizada...',
'downloads.includeSpecials' => 'Incluir especiales',
'downloads.howManyEpisodes' => '¿Cuántos episodios?',
'downloads.invalidEpisodeCount' => 'Introduce un número de episodios válido.',
'downloads.keepSynced' => 'Mantener sincronizado',
'downloads.downloadOnce' => 'Descargar una vez',
'downloads.keepNUnwatched' => ({required Object count}) => 'Mantener ${count} sin ver',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesFr extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Supprimer de "Continuer à regarder"';
@override String errorLoading({required Object error}) => 'Erreur: ${error}';
@override String get streamInterrupted => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.';
@override String get liveStreamInterrupted => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.';
@override String get fileInfoNotAvailable => 'Informations sur le fichier non disponibles';
@override String errorLoadingFileInfo({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}';
@override String get errorLoadingSeries => 'Erreur lors du chargement de la série';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvFr extends TranslationsLiveTvEn {
@override String get watchChannel => 'Regarder la chaîne';
@override String get favorites => 'Favoris';
@override String get reorderFavorites => 'Réorganiser les favoris';
@override String get favoritesLoadFailed => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.';
@override String get joinSession => 'Rejoindre la session en cours';
@override String watchFromStart({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)';
@override String get watchLive => 'Regarder en direct';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsFr extends TranslationsDownloadsEn {
@override String get customAmount => 'Quantité personnalisée...';
@override String get includeSpecials => 'Inclure les spéciaux';
@override String get howManyEpisodes => 'Combien d\'épisodes ?';
@override String get invalidEpisodeCount => 'Saisissez un nombre d\'épisodes valide.';
@override String get keepSynced => 'Garder synchronisé';
@override String get downloadOnce => 'Télécharger une fois';
@override String keepNUnwatched({required Object count}) => 'Garder ${count} non vus';
@@ -2601,6 +2604,7 @@ extension on TranslationsFr {
'messages.removedFromContinueWatching' => 'Supprimer de "Continuer à regarder"',
'messages.errorLoading' => ({required Object error}) => 'Erreur: ${error}',
'messages.streamInterrupted' => 'La lecture a été interrompue. Appuyez sur Lecture ou avancez pour réessayer.',
'messages.liveStreamInterrupted' => 'Le direct a été interrompu. Appuyez sur Lecture pour réessayer.',
'messages.fileInfoNotAvailable' => 'Informations sur le fichier non disponibles',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erreur lors du chargement des informations sur le fichier: ${error}',
'messages.errorLoadingSeries' => 'Erreur lors du chargement de la série',
@@ -2608,9 +2612,9 @@ extension on TranslationsFr {
'messages.noDescriptionAvailable' => 'Aucune description disponible',
'messages.noProfilesAvailable' => 'Aucun profil disponible',
'messages.contactAdminForProfiles' => 'Contactez votre administrateur serveur pour ajouter des profils',
'messages.unableToDetermineLibrarySection' => 'Impossible de déterminer la section de la bibliothèque pour cet élément',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Impossible de déterminer la section de la bibliothèque pour cet élément',
'messages.logsCleared' => 'Logs effacés',
'messages.logsCopied' => 'Logs copiés dans le presse-papier',
'messages.noLogsAvailable' => 'Aucun log disponible',
@@ -2935,6 +2939,7 @@ extension on TranslationsFr {
'liveTv.watchChannel' => 'Regarder la chaîne',
'liveTv.favorites' => 'Favoris',
'liveTv.reorderFavorites' => 'Réorganiser les favoris',
'liveTv.favoritesLoadFailed' => 'Impossible de charger les favoris. Vérifiez votre connexion et réessayez.',
'liveTv.joinSession' => 'Rejoindre la session en cours',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Regarder depuis le début (il y a ${minutes} min)',
'liveTv.watchLive' => 'Regarder en direct',
@@ -3121,10 +3126,10 @@ extension on TranslationsFr {
'downloads.episodesQueued' => ({required Object count}) => '${count} épisodes en attente de téléchargement',
'downloads.downloadDeleted' => 'Télécharger supprimé',
'downloads.deleteConfirm' => ({required Object title}) => 'Supprimer "${title}" de cet appareil ?',
'downloads.cancelledDownloadTitle' => 'Téléchargement annulé',
'downloads.cancelledDownloadMessage' => 'Ce téléchargement a été annulé. Que voulez-vous faire ?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Téléchargement annulé',
'downloads.cancelledDownloadMessage' => 'Ce téléchargement a été annulé. Que voulez-vous faire ?',
'downloads.allEpisodesAlreadyDownloaded' => 'Tous les épisodes sont déjà téléchargés',
'downloads.resumeDownload' => 'Reprendre le téléchargement',
'downloads.cancelledDownload' => 'Téléchargement annulé',
@@ -3148,6 +3153,7 @@ extension on TranslationsFr {
'downloads.customAmount' => 'Quantité personnalisée...',
'downloads.includeSpecials' => 'Inclure les spéciaux',
'downloads.howManyEpisodes' => 'Combien d\'épisodes ?',
'downloads.invalidEpisodeCount' => 'Saisissez un nombre d\'épisodes valide.',
'downloads.keepSynced' => 'Garder synchronisé',
'downloads.downloadOnce' => 'Télécharger une fois',
'downloads.keepNUnwatched' => ({required Object count}) => 'Garder ${count} non vus',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesIt extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Rimosso da Continua a guardare';
@override String errorLoading({required Object error}) => 'Errore: ${error}';
@override String get streamInterrupted => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.';
@override String get liveStreamInterrupted => 'La diretta si è interrotta. Premi Riproduci per riprovare.';
@override String get fileInfoNotAvailable => 'Informazioni sul file non disponibili';
@override String errorLoadingFileInfo({required Object error}) => 'Errore caricamento informazioni sul file: ${error}';
@override String get errorLoadingSeries => 'Errore caricamento serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvIt extends TranslationsLiveTvEn {
@override String get watchChannel => 'Guarda canale';
@override String get favorites => 'Preferiti';
@override String get reorderFavorites => 'Riordina preferiti';
@override String get favoritesLoadFailed => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.';
@override String get joinSession => 'Partecipa alla sessione in corso';
@override String watchFromStart({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)';
@override String get watchLive => 'Guarda in diretta';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsIt extends TranslationsDownloadsEn {
@override String get customAmount => 'Quantità personalizzata...';
@override String get includeSpecials => 'Includi gli speciali';
@override String get howManyEpisodes => 'Quanti episodi?';
@override String get invalidEpisodeCount => 'Inserisci un numero di episodi valido.';
@override String get keepSynced => 'Mantieni sincronizzato';
@override String get downloadOnce => 'Scarica una volta';
@override String keepNUnwatched({required Object count}) => 'Mantieni ${count} non visti';
@@ -2601,6 +2604,7 @@ extension on TranslationsIt {
'messages.removedFromContinueWatching' => 'Rimosso da Continua a guardare',
'messages.errorLoading' => ({required Object error}) => 'Errore: ${error}',
'messages.streamInterrupted' => 'La riproduzione si è interrotta. Premi Riproduci o scorri per riprovare.',
'messages.liveStreamInterrupted' => 'La diretta si è interrotta. Premi Riproduci per riprovare.',
'messages.fileInfoNotAvailable' => 'Informazioni sul file non disponibili',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Errore caricamento informazioni sul file: ${error}',
'messages.errorLoadingSeries' => 'Errore caricamento serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsIt {
'messages.noDescriptionAvailable' => 'Nessuna descrizione disponibile',
'messages.noProfilesAvailable' => 'Nessun profilo disponibile',
'messages.contactAdminForProfiles' => 'Contatta l\'amministratore del server per aggiungere profili',
'messages.unableToDetermineLibrarySection' => 'Impossibile determinare la sezione della libreria per questo elemento',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Impossibile determinare la sezione della libreria per questo elemento',
'messages.logsCleared' => 'Log eliminati',
'messages.logsCopied' => 'Log copiati negli appunti',
'messages.noLogsAvailable' => 'Nessun log disponibile',
@@ -2935,6 +2939,7 @@ extension on TranslationsIt {
'liveTv.watchChannel' => 'Guarda canale',
'liveTv.favorites' => 'Preferiti',
'liveTv.reorderFavorites' => 'Riordina preferiti',
'liveTv.favoritesLoadFailed' => 'Impossibile caricare i preferiti. Controlla la connessione e riprova.',
'liveTv.joinSession' => 'Partecipa alla sessione in corso',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Guarda dall\'inizio (${minutes} min fa)',
'liveTv.watchLive' => 'Guarda in diretta',
@@ -3121,10 +3126,10 @@ extension on TranslationsIt {
'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download',
'downloads.downloadDeleted' => 'Download eliminato',
'downloads.deleteConfirm' => ({required Object title}) => 'Eliminare "${title}" da questo dispositivo?',
'downloads.cancelledDownloadTitle' => 'Download annullato',
'downloads.cancelledDownloadMessage' => 'Questo download è stato annullato. Cosa vuoi fare?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Download annullato',
'downloads.cancelledDownloadMessage' => 'Questo download è stato annullato. Cosa vuoi fare?',
'downloads.allEpisodesAlreadyDownloaded' => 'Tutti gli episodi sono già stati scaricati',
'downloads.resumeDownload' => 'Riprendi download',
'downloads.cancelledDownload' => 'Download annullato',
@@ -3148,6 +3153,7 @@ extension on TranslationsIt {
'downloads.customAmount' => 'Quantità personalizzata...',
'downloads.includeSpecials' => 'Includi gli speciali',
'downloads.howManyEpisodes' => 'Quanti episodi?',
'downloads.invalidEpisodeCount' => 'Inserisci un numero di episodi valido.',
'downloads.keepSynced' => 'Mantieni sincronizzato',
'downloads.downloadOnce' => 'Scarica una volta',
'downloads.keepNUnwatched' => ({required Object count}) => 'Mantieni ${count} non visti',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesJa extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => '視聴中から削除しました';
@override String errorLoading({required Object error}) => 'エラー: ${error}';
@override String get streamInterrupted => 'ストリームが中断されました。再生を押すかシークして再試行してください。';
@override String get liveStreamInterrupted => 'ライブストリームが中断されました。再生を押して再試行してください。';
@override String get fileInfoNotAvailable => 'ファイル情報が利用できません';
@override String errorLoadingFileInfo({required Object error}) => 'ファイル情報の読み込みエラー: ${error}';
@override String get errorLoadingSeries => 'シリーズの読み込みエラー';
@@ -1123,6 +1124,7 @@ class _TranslationsLiveTvJa extends TranslationsLiveTvEn {
@override String get watchChannel => 'チャンネルを視聴';
@override String get favorites => 'お気に入り';
@override String get reorderFavorites => 'お気に入りを並べ替え';
@override String get favoritesLoadFailed => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。';
@override String get joinSession => '進行中のセッションに参加';
@override String watchFromStart({required Object minutes}) => '最初から視聴(${minutes}分前に開始)';
@override String get watchLive => 'ライブで視聴';
@@ -1381,6 +1383,7 @@ class _TranslationsDownloadsJa extends TranslationsDownloadsEn {
@override String get customAmount => '数を指定...';
@override String get includeSpecials => 'スペシャルを含める';
@override String get howManyEpisodes => '何エピソード?';
@override String get invalidEpisodeCount => '有効なエピソード数を入力してください。';
@override String get keepSynced => '同期を維持';
@override String get downloadOnce => '一度だけダウンロード';
@override String keepNUnwatched({required Object count}) => '未視聴を${count}件保持';
@@ -2599,6 +2602,7 @@ extension on TranslationsJa {
'messages.removedFromContinueWatching' => '視聴中から削除しました',
'messages.errorLoading' => ({required Object error}) => 'エラー: ${error}',
'messages.streamInterrupted' => 'ストリームが中断されました。再生を押すかシークして再試行してください。',
'messages.liveStreamInterrupted' => 'ライブストリームが中断されました。再生を押して再試行してください。',
'messages.fileInfoNotAvailable' => 'ファイル情報が利用できません',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'ファイル情報の読み込みエラー: ${error}',
'messages.errorLoadingSeries' => 'シリーズの読み込みエラー',
@@ -2606,9 +2610,9 @@ extension on TranslationsJa {
'messages.noDescriptionAvailable' => '説明はありません',
'messages.noProfilesAvailable' => '利用可能なプロフィールがありません',
'messages.contactAdminForProfiles' => 'プロファイルを追加するにはサーバー管理者に連絡してください',
'messages.unableToDetermineLibrarySection' => 'このアイテムのライブラリセクションを判別できません',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'このアイテムのライブラリセクションを判別できません',
'messages.logsCleared' => 'ログをクリアしました',
'messages.logsCopied' => 'ログをクリップボードにコピーしました',
'messages.noLogsAvailable' => 'ログがありません',
@@ -2933,6 +2937,7 @@ extension on TranslationsJa {
'liveTv.watchChannel' => 'チャンネルを視聴',
'liveTv.favorites' => 'お気に入り',
'liveTv.reorderFavorites' => 'お気に入りを並べ替え',
'liveTv.favoritesLoadFailed' => 'お気に入りを読み込めませんでした。接続を確認してもう一度お試しください。',
'liveTv.joinSession' => '進行中のセッションに参加',
'liveTv.watchFromStart' => ({required Object minutes}) => '最初から視聴(${minutes}分前に開始)',
'liveTv.watchLive' => 'ライブで視聴',
@@ -3119,10 +3124,10 @@ extension on TranslationsJa {
'downloads.episodesQueued' => ({required Object count}) => '${count}エピソードをダウンロードキューに追加しました',
'downloads.downloadDeleted' => 'ダウンロードを削除しました',
'downloads.deleteConfirm' => ({required Object title}) => 'このデバイスから「${title}」を削除しますか?',
'downloads.cancelledDownloadTitle' => 'キャンセルされたダウンロード',
'downloads.cancelledDownloadMessage' => 'このダウンロードはキャンセルされました。どうしますか?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'キャンセルされたダウンロード',
'downloads.cancelledDownloadMessage' => 'このダウンロードはキャンセルされました。どうしますか?',
'downloads.allEpisodesAlreadyDownloaded' => 'すべてのエピソードはすでにダウンロード済みです',
'downloads.resumeDownload' => 'ダウンロードを再開',
'downloads.cancelledDownload' => 'キャンセルされたダウンロード',
@@ -3146,6 +3151,7 @@ extension on TranslationsJa {
'downloads.customAmount' => '数を指定...',
'downloads.includeSpecials' => 'スペシャルを含める',
'downloads.howManyEpisodes' => '何エピソード?',
'downloads.invalidEpisodeCount' => '有効なエピソード数を入力してください。',
'downloads.keepSynced' => '同期を維持',
'downloads.downloadOnce' => '一度だけダウンロード',
'downloads.keepNUnwatched' => ({required Object count}) => '未視聴を${count}件保持',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesKo extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => '계속 시청 목록에서 제거됨';
@override String errorLoading({required Object error}) => '오류: ${error}';
@override String get streamInterrupted => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.';
@override String get liveStreamInterrupted => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.';
@override String get fileInfoNotAvailable => '파일 정보가 없습니다';
@override String errorLoadingFileInfo({required Object error}) => '파일 정보 로딩 중 오류: ${error}';
@override String get errorLoadingSeries => '시리즈 로딩 중 오류';
@@ -1123,6 +1124,7 @@ class _TranslationsLiveTvKo extends TranslationsLiveTvEn {
@override String get watchChannel => '채널 시청';
@override String get favorites => '즐겨찾기';
@override String get reorderFavorites => '즐겨찾기 순서 변경';
@override String get favoritesLoadFailed => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.';
@override String get joinSession => '진행 중인 세션 참여';
@override String watchFromStart({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)';
@override String get watchLive => '실시간 시청';
@@ -1381,6 +1383,7 @@ class _TranslationsDownloadsKo extends TranslationsDownloadsEn {
@override String get customAmount => '직접 입력...';
@override String get includeSpecials => '스페셜 포함';
@override String get howManyEpisodes => '몇 개의 에피소드?';
@override String get invalidEpisodeCount => '올바른 에피소드 수를 입력하세요.';
@override String get keepSynced => '동기화 유지';
@override String get downloadOnce => '한 번만 다운로드';
@override String keepNUnwatched({required Object count}) => '미시청 ${count}개 유지';
@@ -2599,6 +2602,7 @@ extension on TranslationsKo {
'messages.removedFromContinueWatching' => '계속 시청 목록에서 제거됨',
'messages.errorLoading' => ({required Object error}) => '오류: ${error}',
'messages.streamInterrupted' => '스트림이 중단되었습니다. 재생을 누르거나 탐색하여 다시 시도하세요.',
'messages.liveStreamInterrupted' => '라이브 스트림이 중단되었습니다. 재생을 눌러 다시 시도하세요.',
'messages.fileInfoNotAvailable' => '파일 정보가 없습니다',
'messages.errorLoadingFileInfo' => ({required Object error}) => '파일 정보 로딩 중 오류: ${error}',
'messages.errorLoadingSeries' => '시리즈 로딩 중 오류',
@@ -2606,9 +2610,9 @@ extension on TranslationsKo {
'messages.noDescriptionAvailable' => '설명이 없습니다',
'messages.noProfilesAvailable' => '사용 가능한 프로필이 없습니다',
'messages.contactAdminForProfiles' => '프로필을 추가하려면 서버 관리자에게 문의하세요',
'messages.unableToDetermineLibrarySection' => '이 항목의 라이브러리 섹션을 확인할 수 없습니다',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => '이 항목의 라이브러리 섹션을 확인할 수 없습니다',
'messages.logsCleared' => '로그가 삭제 되었습니다',
'messages.logsCopied' => '로그가 클립보드에 복사 되었습니다',
'messages.noLogsAvailable' => '사용 가능한 로그가 없습니다',
@@ -2933,6 +2937,7 @@ extension on TranslationsKo {
'liveTv.watchChannel' => '채널 시청',
'liveTv.favorites' => '즐겨찾기',
'liveTv.reorderFavorites' => '즐겨찾기 순서 변경',
'liveTv.favoritesLoadFailed' => '즐겨찾기를 불러올 수 없습니다. 연결을 확인하고 다시 시도하세요.',
'liveTv.joinSession' => '진행 중인 세션 참여',
'liveTv.watchFromStart' => ({required Object minutes}) => '처음부터 시청 (${minutes}분 전 시작)',
'liveTv.watchLive' => '실시간 시청',
@@ -3119,10 +3124,10 @@ extension on TranslationsKo {
'downloads.episodesQueued' => ({required Object count}) => '${count} 에피소드가 다운로드 대기열에 추가 되었습니다',
'downloads.downloadDeleted' => '다운로드 삭제됨',
'downloads.deleteConfirm' => ({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?',
'downloads.cancelledDownloadTitle' => '취소된 다운로드',
'downloads.cancelledDownloadMessage' => '이 다운로드가 취소되었습니다. 어떻게 하시겠습니까?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => '취소된 다운로드',
'downloads.cancelledDownloadMessage' => '이 다운로드가 취소되었습니다. 어떻게 하시겠습니까?',
'downloads.allEpisodesAlreadyDownloaded' => '모든 에피소드가 이미 다운로드되었습니다',
'downloads.resumeDownload' => '다운로드 재개',
'downloads.cancelledDownload' => '취소된 다운로드',
@@ -3146,6 +3151,7 @@ extension on TranslationsKo {
'downloads.customAmount' => '직접 입력...',
'downloads.includeSpecials' => '스페셜 포함',
'downloads.howManyEpisodes' => '몇 개의 에피소드?',
'downloads.invalidEpisodeCount' => '올바른 에피소드 수를 입력하세요.',
'downloads.keepSynced' => '동기화 유지',
'downloads.downloadOnce' => '한 번만 다운로드',
'downloads.keepNUnwatched' => ({required Object count}) => '미시청 ${count}개 유지',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesNb extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Fjernet fra Fortsett å se';
@override String errorLoading({required Object error}) => 'Feil: ${error}';
@override String get streamInterrupted => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.';
@override String get liveStreamInterrupted => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.';
@override String get fileInfoNotAvailable => 'Filinformasjon ikke tilgjengelig';
@override String errorLoadingFileInfo({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}';
@override String get errorLoadingSeries => 'Feil ved lasting av serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvNb extends TranslationsLiveTvEn {
@override String get watchChannel => 'Se kanal';
@override String get favorites => 'Favoritter';
@override String get reorderFavorites => 'Endre rekkefølge på favoritter';
@override String get favoritesLoadFailed => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.';
@override String get joinSession => 'Bli med i pågående økt';
@override String watchFromStart({required Object minutes}) => 'Se fra starten (${minutes} min siden)';
@override String get watchLive => 'Se direkte';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsNb extends TranslationsDownloadsEn {
@override String get customAmount => 'Egendefinert antall...';
@override String get includeSpecials => 'Inkluder spesialepisoder';
@override String get howManyEpisodes => 'Hvor mange episoder?';
@override String get invalidEpisodeCount => 'Angi et gyldig antall episoder.';
@override String get keepSynced => 'Hold synkronisert';
@override String get downloadOnce => 'Last ned én gang';
@override String keepNUnwatched({required Object count}) => 'Behold ${count} usette';
@@ -2601,6 +2604,7 @@ extension on TranslationsNb {
'messages.removedFromContinueWatching' => 'Fjernet fra Fortsett å se',
'messages.errorLoading' => ({required Object error}) => 'Feil: ${error}',
'messages.streamInterrupted' => 'Avspillingen ble avbrutt. Trykk på Spill av eller spol for å prøve på nytt.',
'messages.liveStreamInterrupted' => 'Direktesendingen ble avbrutt. Trykk på Spill av for å prøve på nytt.',
'messages.fileInfoNotAvailable' => 'Filinformasjon ikke tilgjengelig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Feil ved lasting av filinformasjon: ${error}',
'messages.errorLoadingSeries' => 'Feil ved lasting av serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsNb {
'messages.noDescriptionAvailable' => 'Ingen beskrivelse tilgjengelig',
'messages.noProfilesAvailable' => 'Ingen profiler tilgjengelige',
'messages.contactAdminForProfiles' => 'Kontakt serveradministratoren din for å legge til profiler',
'messages.unableToDetermineLibrarySection' => 'Kan ikke fastslå bibliotekseksjonen for dette elementet',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Kan ikke fastslå bibliotekseksjonen for dette elementet',
'messages.logsCleared' => 'Logger tømt',
'messages.logsCopied' => 'Logger kopiert til utklippstavle',
'messages.noLogsAvailable' => 'Ingen logger tilgjengelig',
@@ -2935,6 +2939,7 @@ extension on TranslationsNb {
'liveTv.watchChannel' => 'Se kanal',
'liveTv.favorites' => 'Favoritter',
'liveTv.reorderFavorites' => 'Endre rekkefølge på favoritter',
'liveTv.favoritesLoadFailed' => 'Kunne ikke laste inn favoritter. Kontroller tilkoblingen og prøv på nytt.',
'liveTv.joinSession' => 'Bli med i pågående økt',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Se fra starten (${minutes} min siden)',
'liveTv.watchLive' => 'Se direkte',
@@ -3121,10 +3126,10 @@ extension on TranslationsNb {
'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i nedlastingskø',
'downloads.downloadDeleted' => 'Nedlasting slettet',
'downloads.deleteConfirm' => ({required Object title}) => 'Slette "${title}" fra denne enheten?',
'downloads.cancelledDownloadTitle' => 'Avbrutt nedlasting',
'downloads.cancelledDownloadMessage' => 'Denne nedlastingen ble avbrutt. Hva vil du gjøre?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Avbrutt nedlasting',
'downloads.cancelledDownloadMessage' => 'Denne nedlastingen ble avbrutt. Hva vil du gjøre?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle episoder er allerede lastet ned',
'downloads.resumeDownload' => 'Gjenoppta nedlasting',
'downloads.cancelledDownload' => 'Avbrutt nedlasting',
@@ -3148,6 +3153,7 @@ extension on TranslationsNb {
'downloads.customAmount' => 'Egendefinert antall...',
'downloads.includeSpecials' => 'Inkluder spesialepisoder',
'downloads.howManyEpisodes' => 'Hvor mange episoder?',
'downloads.invalidEpisodeCount' => 'Angi et gyldig antall episoder.',
'downloads.keepSynced' => 'Hold synkronisert',
'downloads.downloadOnce' => 'Last ned én gang',
'downloads.keepNUnwatched' => ({required Object count}) => 'Behold ${count} usette',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesNl extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Verwijderd uit Doorgaan met kijken';
@override String errorLoading({required Object error}) => 'Fout: ${error}';
@override String get streamInterrupted => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.';
@override String get liveStreamInterrupted => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.';
@override String get fileInfoNotAvailable => 'Bestand informatie niet beschikbaar';
@override String errorLoadingFileInfo({required Object error}) => 'Fout bij laden bestand info: ${error}';
@override String get errorLoadingSeries => 'Fout bij laden serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvNl extends TranslationsLiveTvEn {
@override String get watchChannel => 'Kanaal bekijken';
@override String get favorites => 'Favorieten';
@override String get reorderFavorites => 'Favorieten herordenen';
@override String get favoritesLoadFailed => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.';
@override String get joinSession => 'Deelnemen aan lopende sessie';
@override String watchFromStart({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)';
@override String get watchLive => 'Live kijken';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsNl extends TranslationsDownloadsEn {
@override String get customAmount => 'Aangepast aantal...';
@override String get includeSpecials => 'Specials opnemen';
@override String get howManyEpisodes => 'Hoeveel afleveringen?';
@override String get invalidEpisodeCount => 'Voer een geldig aantal afleveringen in.';
@override String get keepSynced => 'Gesynchroniseerd houden';
@override String get downloadOnce => 'Eenmalig downloaden';
@override String keepNUnwatched({required Object count}) => '${count} onbekeken behouden';
@@ -2601,6 +2604,7 @@ extension on TranslationsNl {
'messages.removedFromContinueWatching' => 'Verwijderd uit Doorgaan met kijken',
'messages.errorLoading' => ({required Object error}) => 'Fout: ${error}',
'messages.streamInterrupted' => 'De stream is onderbroken. Druk op afspelen of spoel om het opnieuw te proberen.',
'messages.liveStreamInterrupted' => 'De livestream is onderbroken. Druk op afspelen om het opnieuw te proberen.',
'messages.fileInfoNotAvailable' => 'Bestand informatie niet beschikbaar',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fout bij laden bestand info: ${error}',
'messages.errorLoadingSeries' => 'Fout bij laden serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsNl {
'messages.noDescriptionAvailable' => 'Geen beschrijving beschikbaar',
'messages.noProfilesAvailable' => 'Geen profielen beschikbaar',
'messages.contactAdminForProfiles' => 'Neem contact op met je serverbeheerder om profielen toe te voegen',
'messages.unableToDetermineLibrarySection' => 'Kan bibliotheeksectie voor dit item niet bepalen',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Kan bibliotheeksectie voor dit item niet bepalen',
'messages.logsCleared' => 'Logs gewist',
'messages.logsCopied' => 'Logs gekopieerd naar klembord',
'messages.noLogsAvailable' => 'Geen logs beschikbaar',
@@ -2935,6 +2939,7 @@ extension on TranslationsNl {
'liveTv.watchChannel' => 'Kanaal bekijken',
'liveTv.favorites' => 'Favorieten',
'liveTv.reorderFavorites' => 'Favorieten herordenen',
'liveTv.favoritesLoadFailed' => 'Favorieten konden niet worden geladen. Controleer je verbinding en probeer het opnieuw.',
'liveTv.joinSession' => 'Deelnemen aan lopende sessie',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Kijk vanaf het begin (${minutes} min geleden)',
'liveTv.watchLive' => 'Live kijken',
@@ -3121,10 +3126,10 @@ extension on TranslationsNl {
'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download',
'downloads.downloadDeleted' => 'Download verwijderd',
'downloads.deleteConfirm' => ({required Object title}) => '"${title}" van dit apparaat verwijderen?',
'downloads.cancelledDownloadTitle' => 'Geannuleerde download',
'downloads.cancelledDownloadMessage' => 'Deze download is geannuleerd. Wat wil je doen?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Geannuleerde download',
'downloads.cancelledDownloadMessage' => 'Deze download is geannuleerd. Wat wil je doen?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alle afleveringen zijn al gedownload',
'downloads.resumeDownload' => 'Download hervatten',
'downloads.cancelledDownload' => 'Geannuleerde download',
@@ -3148,6 +3153,7 @@ extension on TranslationsNl {
'downloads.customAmount' => 'Aangepast aantal...',
'downloads.includeSpecials' => 'Specials opnemen',
'downloads.howManyEpisodes' => 'Hoeveel afleveringen?',
'downloads.invalidEpisodeCount' => 'Voer een geldig aantal afleveringen in.',
'downloads.keepSynced' => 'Gesynchroniseerd houden',
'downloads.downloadOnce' => 'Eenmalig downloaden',
'downloads.keepNUnwatched' => ({required Object count}) => '${count} onbekeken behouden',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesPl extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Usunięto z kontynuowania oglądania';
@override String errorLoading({required Object error}) => 'Błąd: ${error}';
@override String get streamInterrupted => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.';
@override String get liveStreamInterrupted => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.';
@override String get fileInfoNotAvailable => 'Informacje o pliku niedostępne';
@override String errorLoadingFileInfo({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}';
@override String get errorLoadingSeries => 'Błąd ładowania serialu';
@@ -1126,6 +1127,7 @@ class _TranslationsLiveTvPl extends TranslationsLiveTvEn {
@override String get watchChannel => 'Oglądaj kanał';
@override String get favorites => 'Ulubione';
@override String get reorderFavorites => 'Zmień kolejność ulubionych';
@override String get favoritesLoadFailed => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.';
@override String get joinSession => 'Dołącz do trwającej sesji';
@override String watchFromStart({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)';
@override String get watchLive => 'Oglądaj na żywo';
@@ -1387,6 +1389,7 @@ class _TranslationsDownloadsPl extends TranslationsDownloadsEn {
@override String get customAmount => 'Własna ilość...';
@override String get includeSpecials => 'Uwzględnij odcinki specjalne';
@override String get howManyEpisodes => 'Ile odcinków?';
@override String get invalidEpisodeCount => 'Wprowadź prawidłową liczbę odcinków.';
@override String get keepSynced => 'Synchronizuj na bieżąco';
@override String get downloadOnce => 'Pobierz raz';
@override String keepNUnwatched({required Object count}) => 'Zachowaj ${count} nieobejrzanych';
@@ -2605,6 +2608,7 @@ extension on TranslationsPl {
'messages.removedFromContinueWatching' => 'Usunięto z kontynuowania oglądania',
'messages.errorLoading' => ({required Object error}) => 'Błąd: ${error}',
'messages.streamInterrupted' => 'Strumień został przerwany. Naciśnij odtwarzanie lub przewiń, aby spróbować ponownie.',
'messages.liveStreamInterrupted' => 'Transmisja na żywo została przerwana. Naciśnij odtwarzanie, aby spróbować ponownie.',
'messages.fileInfoNotAvailable' => 'Informacje o pliku niedostępne',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Błąd ładowania informacji o pliku: ${error}',
'messages.errorLoadingSeries' => 'Błąd ładowania serialu',
@@ -2612,9 +2616,9 @@ extension on TranslationsPl {
'messages.noDescriptionAvailable' => 'Brak dostępnego opisu',
'messages.noProfilesAvailable' => 'Brak dostępnych profili',
'messages.contactAdminForProfiles' => 'Skontaktuj się z administratorem serwera, aby dodać profile',
'messages.unableToDetermineLibrarySection' => 'Nie można określić sekcji biblioteki dla tego elementu',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Nie można określić sekcji biblioteki dla tego elementu',
'messages.logsCleared' => 'Logi wyczyszczone',
'messages.logsCopied' => 'Logi skopiowane do schowka',
'messages.noLogsAvailable' => 'Brak dostępnych logów',
@@ -2939,6 +2943,7 @@ extension on TranslationsPl {
'liveTv.watchChannel' => 'Oglądaj kanał',
'liveTv.favorites' => 'Ulubione',
'liveTv.reorderFavorites' => 'Zmień kolejność ulubionych',
'liveTv.favoritesLoadFailed' => 'Nie udało się wczytać ulubionych. Sprawdź połączenie i spróbuj ponownie.',
'liveTv.joinSession' => 'Dołącz do trwającej sesji',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Oglądaj od początku (${minutes} min temu)',
'liveTv.watchLive' => 'Oglądaj na żywo',
@@ -3125,10 +3130,10 @@ extension on TranslationsPl {
'downloads.episodesQueued' => ({required Object count}) => '${count} odcinków w kolejce pobierania',
'downloads.downloadDeleted' => 'Pobranie usunięte',
'downloads.deleteConfirm' => ({required Object title}) => 'Usunąć "${title}" z tego urządzenia?',
'downloads.cancelledDownloadTitle' => 'Anulowane pobieranie',
'downloads.cancelledDownloadMessage' => 'To pobieranie zostało anulowane. Co chcesz zrobić?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Anulowane pobieranie',
'downloads.cancelledDownloadMessage' => 'To pobieranie zostało anulowane. Co chcesz zrobić?',
'downloads.allEpisodesAlreadyDownloaded' => 'Wszystkie odcinki są już pobrane',
'downloads.resumeDownload' => 'Wznów pobieranie',
'downloads.cancelledDownload' => 'Anulowane pobieranie',
@@ -3152,6 +3157,7 @@ extension on TranslationsPl {
'downloads.customAmount' => 'Własna ilość...',
'downloads.includeSpecials' => 'Uwzględnij odcinki specjalne',
'downloads.howManyEpisodes' => 'Ile odcinków?',
'downloads.invalidEpisodeCount' => 'Wprowadź prawidłową liczbę odcinków.',
'downloads.keepSynced' => 'Synchronizuj na bieżąco',
'downloads.downloadOnce' => 'Pobierz raz',
'downloads.keepNUnwatched' => ({required Object count}) => 'Zachowaj ${count} nieobejrzanych',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesPt extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Removido de Continuar Assistindo';
@override String errorLoading({required Object error}) => 'Erro: ${error}';
@override String get streamInterrupted => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.';
@override String get liveStreamInterrupted => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.';
@override String get fileInfoNotAvailable => 'Informações do arquivo não disponíveis';
@override String errorLoadingFileInfo({required Object error}) => 'Erro ao carregar info do arquivo: ${error}';
@override String get errorLoadingSeries => 'Erro ao carregar série';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvPt extends TranslationsLiveTvEn {
@override String get watchChannel => 'Assistir Canal';
@override String get favorites => 'Favoritos';
@override String get reorderFavorites => 'Reordenar favoritos';
@override String get favoritesLoadFailed => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.';
@override String get joinSession => 'Entrar na sessão em andamento';
@override String watchFromStart({required Object minutes}) => 'Assistir do início (${minutes} min atrás)';
@override String get watchLive => 'Assistir ao vivo';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsPt extends TranslationsDownloadsEn {
@override String get customAmount => 'Quantidade personalizada...';
@override String get includeSpecials => 'Incluir especiais';
@override String get howManyEpisodes => 'Quantos episódios?';
@override String get invalidEpisodeCount => 'Insira uma quantidade válida de episódios.';
@override String get keepSynced => 'Manter sincronizado';
@override String get downloadOnce => 'Baixar uma vez';
@override String keepNUnwatched({required Object count}) => 'Manter ${count} não assistidos';
@@ -2601,6 +2604,7 @@ extension on TranslationsPt {
'messages.removedFromContinueWatching' => 'Removido de Continuar Assistindo',
'messages.errorLoading' => ({required Object error}) => 'Erro: ${error}',
'messages.streamInterrupted' => 'A transmissão foi interrompida. Toque em reproduzir ou avance para tentar novamente.',
'messages.liveStreamInterrupted' => 'A transmissão ao vivo foi interrompida. Toque em reproduzir para tentar novamente.',
'messages.fileInfoNotAvailable' => 'Informações do arquivo não disponíveis',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Erro ao carregar info do arquivo: ${error}',
'messages.errorLoadingSeries' => 'Erro ao carregar série',
@@ -2608,9 +2612,9 @@ extension on TranslationsPt {
'messages.noDescriptionAvailable' => 'Nenhuma descrição disponível',
'messages.noProfilesAvailable' => 'Nenhum perfil disponível',
'messages.contactAdminForProfiles' => 'Contate o administrador do servidor para adicionar perfis',
'messages.unableToDetermineLibrarySection' => 'Não é possível determinar a secção da biblioteca para este item',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Não é possível determinar a secção da biblioteca para este item',
'messages.logsCleared' => 'Logs limpos',
'messages.logsCopied' => 'Logs copiados para a área de transferência',
'messages.noLogsAvailable' => 'Nenhum log disponível',
@@ -2935,6 +2939,7 @@ extension on TranslationsPt {
'liveTv.watchChannel' => 'Assistir Canal',
'liveTv.favorites' => 'Favoritos',
'liveTv.reorderFavorites' => 'Reordenar favoritos',
'liveTv.favoritesLoadFailed' => 'Não foi possível carregar os favoritos. Verifique sua conexão e tente novamente.',
'liveTv.joinSession' => 'Entrar na sessão em andamento',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Assistir do início (${minutes} min atrás)',
'liveTv.watchLive' => 'Assistir ao vivo',
@@ -3121,10 +3126,10 @@ extension on TranslationsPt {
'downloads.episodesQueued' => ({required Object count}) => '${count} episódios na fila de download',
'downloads.downloadDeleted' => 'Download excluído',
'downloads.deleteConfirm' => ({required Object title}) => 'Excluir "${title}" deste dispositivo?',
'downloads.cancelledDownloadTitle' => 'Download cancelado',
'downloads.cancelledDownloadMessage' => 'Este download foi cancelado. O que você deseja fazer?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Download cancelado',
'downloads.cancelledDownloadMessage' => 'Este download foi cancelado. O que você deseja fazer?',
'downloads.allEpisodesAlreadyDownloaded' => 'Todos os episódios já foram baixados',
'downloads.resumeDownload' => 'Retomar download',
'downloads.cancelledDownload' => 'Download cancelado',
@@ -3148,6 +3153,7 @@ extension on TranslationsPt {
'downloads.customAmount' => 'Quantidade personalizada...',
'downloads.includeSpecials' => 'Incluir especiais',
'downloads.howManyEpisodes' => 'Quantos episódios?',
'downloads.invalidEpisodeCount' => 'Insira uma quantidade válida de episódios.',
'downloads.keepSynced' => 'Manter sincronizado',
'downloads.downloadOnce' => 'Baixar uma vez',
'downloads.keepNUnwatched' => ({required Object count}) => 'Manter ${count} não assistidos',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesRu extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Удалено из «Продолжить просмотр»';
@override String errorLoading({required Object error}) => 'Ошибка: ${error}';
@override String get streamInterrupted => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.';
@override String get liveStreamInterrupted => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.';
@override String get fileInfoNotAvailable => 'Информация о файле недоступна';
@override String errorLoadingFileInfo({required Object error}) => 'Ошибка загрузки информации о файле: ${error}';
@override String get errorLoadingSeries => 'Ошибка загрузки сериала';
@@ -1126,6 +1127,7 @@ class _TranslationsLiveTvRu extends TranslationsLiveTvEn {
@override String get watchChannel => 'Смотреть канал';
@override String get favorites => 'Избранное';
@override String get reorderFavorites => 'Изменить порядок избранного';
@override String get favoritesLoadFailed => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.';
@override String get joinSession => 'Присоединиться к текущему сеансу';
@override String watchFromStart({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)';
@override String get watchLive => 'Смотреть в прямом эфире';
@@ -1387,6 +1389,7 @@ class _TranslationsDownloadsRu extends TranslationsDownloadsEn {
@override String get customAmount => 'Указать количество...';
@override String get includeSpecials => 'Включить спецвыпуски';
@override String get howManyEpisodes => 'Сколько эпизодов?';
@override String get invalidEpisodeCount => 'Введите допустимое количество эпизодов.';
@override String get keepSynced => 'Синхронизировать';
@override String get downloadOnce => 'Скачать один раз';
@override String keepNUnwatched({required Object count}) => 'Хранить ${count} непросмотренных';
@@ -2605,6 +2608,7 @@ extension on TranslationsRu {
'messages.removedFromContinueWatching' => 'Удалено из «Продолжить просмотр»',
'messages.errorLoading' => ({required Object error}) => 'Ошибка: ${error}',
'messages.streamInterrupted' => 'Поток прервался. Нажмите «Воспроизвести» или перемотайте, чтобы повторить попытку.',
'messages.liveStreamInterrupted' => 'Прямая трансляция прервалась. Нажмите «Воспроизвести», чтобы повторить попытку.',
'messages.fileInfoNotAvailable' => 'Информация о файле недоступна',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Ошибка загрузки информации о файле: ${error}',
'messages.errorLoadingSeries' => 'Ошибка загрузки сериала',
@@ -2612,9 +2616,9 @@ extension on TranslationsRu {
'messages.noDescriptionAvailable' => 'Описание недоступно',
'messages.noProfilesAvailable' => 'Профили недоступны',
'messages.contactAdminForProfiles' => 'Обратитесь к администратору сервера для добавления профилей',
'messages.unableToDetermineLibrarySection' => 'Не удаётся определить раздел библиотеки для этого элемента',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Не удаётся определить раздел библиотеки для этого элемента',
'messages.logsCleared' => 'Логи очищены',
'messages.logsCopied' => 'Логи скопированы в буфер обмена',
'messages.noLogsAvailable' => 'Логи отсутствуют',
@@ -2939,6 +2943,7 @@ extension on TranslationsRu {
'liveTv.watchChannel' => 'Смотреть канал',
'liveTv.favorites' => 'Избранное',
'liveTv.reorderFavorites' => 'Изменить порядок избранного',
'liveTv.favoritesLoadFailed' => 'Не удалось загрузить избранное. Проверьте подключение и повторите попытку.',
'liveTv.joinSession' => 'Присоединиться к текущему сеансу',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Смотреть сначала (${minutes} мин. назад)',
'liveTv.watchLive' => 'Смотреть в прямом эфире',
@@ -3125,10 +3130,10 @@ extension on TranslationsRu {
'downloads.episodesQueued' => ({required Object count}) => '${count} эпизодов поставлено в очередь загрузки',
'downloads.downloadDeleted' => 'Загрузка удалена',
'downloads.deleteConfirm' => ({required Object title}) => 'Удалить "${title}" с этого устройства?',
'downloads.cancelledDownloadTitle' => 'Загрузка отменена',
'downloads.cancelledDownloadMessage' => 'Эта загрузка была отменена. Что вы хотите сделать?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Загрузка отменена',
'downloads.cancelledDownloadMessage' => 'Эта загрузка была отменена. Что вы хотите сделать?',
'downloads.allEpisodesAlreadyDownloaded' => 'Все эпизоды уже загружены',
'downloads.resumeDownload' => 'Возобновить загрузку',
'downloads.cancelledDownload' => 'Загрузка отменена',
@@ -3152,6 +3157,7 @@ extension on TranslationsRu {
'downloads.customAmount' => 'Указать количество...',
'downloads.includeSpecials' => 'Включить спецвыпуски',
'downloads.howManyEpisodes' => 'Сколько эпизодов?',
'downloads.invalidEpisodeCount' => 'Введите допустимое количество эпизодов.',
'downloads.keepSynced' => 'Синхронизировать',
'downloads.downloadOnce' => 'Скачать один раз',
'downloads.keepNUnwatched' => ({required Object count}) => 'Хранить ${count} непросмотренных',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesSv extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => 'Borttagen från Fortsätt titta';
@override String errorLoading({required Object error}) => 'Fel: ${error}';
@override String get streamInterrupted => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.';
@override String get liveStreamInterrupted => 'Livestreamen avbröts. Tryck på play för att försöka igen.';
@override String get fileInfoNotAvailable => 'Filinformation inte tillgänglig';
@override String errorLoadingFileInfo({required Object error}) => 'Fel vid laddning av filinformation: ${error}';
@override String get errorLoadingSeries => 'Fel vid laddning av serie';
@@ -1124,6 +1125,7 @@ class _TranslationsLiveTvSv extends TranslationsLiveTvEn {
@override String get watchChannel => 'Titta på kanal';
@override String get favorites => 'Favoriter';
@override String get reorderFavorites => 'Ordna om favoriter';
@override String get favoritesLoadFailed => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.';
@override String get joinSession => 'Gå med i pågående session';
@override String watchFromStart({required Object minutes}) => 'Titta från början (${minutes} min sedan)';
@override String get watchLive => 'Titta live';
@@ -1383,6 +1385,7 @@ class _TranslationsDownloadsSv extends TranslationsDownloadsEn {
@override String get customAmount => 'Ange antal...';
@override String get includeSpecials => 'Inkludera specialavsnitt';
@override String get howManyEpisodes => 'Hur många avsnitt?';
@override String get invalidEpisodeCount => 'Ange ett giltigt antal avsnitt.';
@override String get keepSynced => 'Håll synkroniserad';
@override String get downloadOnce => 'Ladda ner en gång';
@override String keepNUnwatched({required Object count}) => 'Behåll ${count} osedda';
@@ -2601,6 +2604,7 @@ extension on TranslationsSv {
'messages.removedFromContinueWatching' => 'Borttagen från Fortsätt titta',
'messages.errorLoading' => ({required Object error}) => 'Fel: ${error}',
'messages.streamInterrupted' => 'Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.',
'messages.liveStreamInterrupted' => 'Livestreamen avbröts. Tryck på play för att försöka igen.',
'messages.fileInfoNotAvailable' => 'Filinformation inte tillgänglig',
'messages.errorLoadingFileInfo' => ({required Object error}) => 'Fel vid laddning av filinformation: ${error}',
'messages.errorLoadingSeries' => 'Fel vid laddning av serie',
@@ -2608,9 +2612,9 @@ extension on TranslationsSv {
'messages.noDescriptionAvailable' => 'Ingen beskrivning tillgänglig',
'messages.noProfilesAvailable' => 'Inga profiler tillgängliga',
'messages.contactAdminForProfiles' => 'Kontakta din serveradministratör för att lägga till profiler',
'messages.unableToDetermineLibrarySection' => 'Kan inte avgöra biblioteksavdelningen för detta objekt',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => 'Kan inte avgöra biblioteksavdelningen för detta objekt',
'messages.logsCleared' => 'Loggar rensade',
'messages.logsCopied' => 'Loggar kopierade till urklipp',
'messages.noLogsAvailable' => 'Inga loggar tillgängliga',
@@ -2935,6 +2939,7 @@ extension on TranslationsSv {
'liveTv.watchChannel' => 'Titta på kanal',
'liveTv.favorites' => 'Favoriter',
'liveTv.reorderFavorites' => 'Ordna om favoriter',
'liveTv.favoritesLoadFailed' => 'Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.',
'liveTv.joinSession' => 'Gå med i pågående session',
'liveTv.watchFromStart' => ({required Object minutes}) => 'Titta från början (${minutes} min sedan)',
'liveTv.watchLive' => 'Titta live',
@@ -3121,10 +3126,10 @@ extension on TranslationsSv {
'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning',
'downloads.downloadDeleted' => 'Nedladdning borttagen',
'downloads.deleteConfirm' => ({required Object title}) => 'Ta bort "${title}" från den här enheten?',
'downloads.cancelledDownloadTitle' => 'Avbruten nedladdning',
'downloads.cancelledDownloadMessage' => 'Den här nedladdningen avbröts. Vad vill du göra?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => 'Avbruten nedladdning',
'downloads.cancelledDownloadMessage' => 'Den här nedladdningen avbröts. Vad vill du göra?',
'downloads.allEpisodesAlreadyDownloaded' => 'Alla avsnitt är redan nedladdade',
'downloads.resumeDownload' => 'Återuppta nedladdning',
'downloads.cancelledDownload' => 'Avbruten nedladdning',
@@ -3148,6 +3153,7 @@ extension on TranslationsSv {
'downloads.customAmount' => 'Ange antal...',
'downloads.includeSpecials' => 'Inkludera specialavsnitt',
'downloads.howManyEpisodes' => 'Hur många avsnitt?',
'downloads.invalidEpisodeCount' => 'Ange ett giltigt antal avsnitt.',
'downloads.keepSynced' => 'Håll synkroniserad',
'downloads.downloadOnce' => 'Ladda ner en gång',
'downloads.keepNUnwatched' => ({required Object count}) => 'Behåll ${count} osedda',
+9 -3
View File
@@ -696,6 +696,7 @@ class _TranslationsMessagesZh extends TranslationsMessagesEn {
@override String get removedFromContinueWatching => '已从继续观看中移除';
@override String errorLoading({required Object error}) => '错误: ${error}';
@override String get streamInterrupted => '视频流已中断。按播放键或拖动进度条重试。';
@override String get liveStreamInterrupted => '直播流已中断。按播放键重试。';
@override String get fileInfoNotAvailable => '文件信息不可用';
@override String errorLoadingFileInfo({required Object error}) => '加载文件信息时出错: ${error}';
@override String get errorLoadingSeries => '加载系列时出错';
@@ -1123,6 +1124,7 @@ class _TranslationsLiveTvZh extends TranslationsLiveTvEn {
@override String get watchChannel => '观看频道';
@override String get favorites => '收藏';
@override String get reorderFavorites => '重新排序收藏';
@override String get favoritesLoadFailed => '无法加载收藏。请检查网络连接后重试。';
@override String get joinSession => '加入正在进行的会话';
@override String watchFromStart({required Object minutes}) => '从头观看(${minutes}分钟前开始)';
@override String get watchLive => '观看直播';
@@ -1381,6 +1383,7 @@ class _TranslationsDownloadsZh extends TranslationsDownloadsEn {
@override String get customAmount => '自定义数量...';
@override String get includeSpecials => '包含特别篇';
@override String get howManyEpisodes => '下载几集?';
@override String get invalidEpisodeCount => '请输入有效的集数。';
@override String get keepSynced => '保持同步';
@override String get downloadOnce => '下载一次';
@override String keepNUnwatched({required Object count}) => '保留${count}个未观看';
@@ -2599,6 +2602,7 @@ extension on TranslationsZh {
'messages.removedFromContinueWatching' => '已从继续观看中移除',
'messages.errorLoading' => ({required Object error}) => '错误: ${error}',
'messages.streamInterrupted' => '视频流已中断。按播放键或拖动进度条重试。',
'messages.liveStreamInterrupted' => '直播流已中断。按播放键重试。',
'messages.fileInfoNotAvailable' => '文件信息不可用',
'messages.errorLoadingFileInfo' => ({required Object error}) => '加载文件信息时出错: ${error}',
'messages.errorLoadingSeries' => '加载系列时出错',
@@ -2606,9 +2610,9 @@ extension on TranslationsZh {
'messages.noDescriptionAvailable' => '暂无描述',
'messages.noProfilesAvailable' => '没有可用的用户',
'messages.contactAdminForProfiles' => '请联系服务器管理员添加用户配置',
'messages.unableToDetermineLibrarySection' => '无法确定此项目的库分区',
_ => null,
} ?? switch (path) {
'messages.unableToDetermineLibrarySection' => '无法确定此项目的库分区',
'messages.logsCleared' => '日志已清除',
'messages.logsCopied' => '日志已复制到剪贴板',
'messages.noLogsAvailable' => '没有可用日志',
@@ -2933,6 +2937,7 @@ extension on TranslationsZh {
'liveTv.watchChannel' => '观看频道',
'liveTv.favorites' => '收藏',
'liveTv.reorderFavorites' => '重新排序收藏',
'liveTv.favoritesLoadFailed' => '无法加载收藏。请检查网络连接后重试。',
'liveTv.joinSession' => '加入正在进行的会话',
'liveTv.watchFromStart' => ({required Object minutes}) => '从头观看(${minutes}分钟前开始)',
'liveTv.watchLive' => '观看直播',
@@ -3119,10 +3124,10 @@ extension on TranslationsZh {
'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列',
'downloads.downloadDeleted' => '下载已删除',
'downloads.deleteConfirm' => ({required Object title}) => '要从此设备删除“${title}”吗?',
'downloads.cancelledDownloadTitle' => '已取消的下载',
'downloads.cancelledDownloadMessage' => '此下载已取消。你想怎么做?',
_ => null,
} ?? switch (path) {
'downloads.cancelledDownloadTitle' => '已取消的下载',
'downloads.cancelledDownloadMessage' => '此下载已取消。你想怎么做?',
'downloads.allEpisodesAlreadyDownloaded' => '所有剧集均已下载',
'downloads.resumeDownload' => '继续下载',
'downloads.cancelledDownload' => '已取消的下载',
@@ -3146,6 +3151,7 @@ extension on TranslationsZh {
'downloads.customAmount' => '自定义数量...',
'downloads.includeSpecials' => '包含特别篇',
'downloads.howManyEpisodes' => '下载几集?',
'downloads.invalidEpisodeCount' => '请输入有效的集数。',
'downloads.keepSynced' => '保持同步',
'downloads.downloadOnce' => '下载一次',
'downloads.keepNUnwatched' => ({required Object count}) => '保留${count}个未观看',
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "Borttagen från Fortsätt titta",
"errorLoading": "Fel: ${error}",
"streamInterrupted": "Uppspelningen avbröts. Tryck på play eller spola för att försöka igen.",
"liveStreamInterrupted": "Livestreamen avbröts. Tryck på play för att försöka igen.",
"fileInfoNotAvailable": "Filinformation inte tillgänglig",
"errorLoadingFileInfo": "Fel vid laddning av filinformation: ${error}",
"errorLoadingSeries": "Fel vid laddning av serie",
@@ -916,6 +917,7 @@
"watchChannel": "Titta på kanal",
"favorites": "Favoriter",
"reorderFavorites": "Ordna om favoriter",
"favoritesLoadFailed": "Det gick inte att läsa in favoriter. Kontrollera anslutningen och försök igen.",
"joinSession": "Gå med i pågående session",
"watchFromStart": "Titta från början (${minutes} min sedan)",
"watchLive": "Titta live",
@@ -1140,6 +1142,7 @@
"customAmount": "Ange antal...",
"includeSpecials": "Inkludera specialavsnitt",
"howManyEpisodes": "Hur många avsnitt?",
"invalidEpisodeCount": "Ange ett giltigt antal avsnitt.",
"keepSynced": "Håll synkroniserad",
"downloadOnce": "Ladda ner en gång",
"keepNUnwatched": "Behåll ${count} osedda",
+3
View File
@@ -537,6 +537,7 @@
"removedFromContinueWatching": "已从继续观看中移除",
"errorLoading": "错误: ${error}",
"streamInterrupted": "视频流已中断。按播放键或拖动进度条重试。",
"liveStreamInterrupted": "直播流已中断。按播放键重试。",
"fileInfoNotAvailable": "文件信息不可用",
"errorLoadingFileInfo": "加载文件信息时出错: ${error}",
"errorLoadingSeries": "加载系列时出错",
@@ -915,6 +916,7 @@
"watchChannel": "观看频道",
"favorites": "收藏",
"reorderFavorites": "重新排序收藏",
"favoritesLoadFailed": "无法加载收藏。请检查网络连接后重试。",
"joinSession": "加入正在进行的会话",
"watchFromStart": "从头观看(${minutes}分钟前开始)",
"watchLive": "观看直播",
@@ -1138,6 +1140,7 @@
"customAmount": "自定义数量...",
"includeSpecials": "包含特别篇",
"howManyEpisodes": "下载几集?",
"invalidEpisodeCount": "请输入有效的集数。",
"keepSynced": "保持同步",
"downloadOnce": "下载一次",
"keepNUnwatched": "保留${count}个未观看",
+9 -1
View File
@@ -459,10 +459,18 @@ class PlayerNative extends PlayerBase {
_armedNextUri = null;
_armedNextFd = null;
appLogger.d('MPV-audio: armed entry advanced → playlist-remove 0, ${_uriTail(uri ?? '')}');
unawaited(command(['playlist-remove', '0']));
unawaited(_removeSpentPlaylistEntry());
if (uri != null) trackTransitionController.add(uri);
}
Future<void> _removeSpentPlaylistEntry() async {
try {
await command(['playlist-remove', '0']);
} catch (error, stackTrace) {
appLogger.w('MPV-audio: failed to remove spent playlist entry', error: error, stackTrace: stackTrace);
}
}
@override
void handlePropertyChange(String name, dynamic value) {
if (audioOnly && name == 'playlist-pos') {
+8 -1
View File
@@ -172,7 +172,14 @@ class ActiveProfileBinder {
// notifications. They don't mean the active profile changed, and a
// failed bind intentionally leaves `_lastBoundProfileId` unset so the
// same profile can be retried later.
if (id == _bindingProfileId) return;
if (id == _bindingProfileId) {
// The active id can briefly move away and back while this pass is
// awaiting multiple connection binds. Any component that observed the
// intermediate id may already have returned an empty stale result, so
// the current pass cannot be committed as the final same-id bind.
if (_pendingRebind) _pendingSameIdRebind = true;
return;
}
// A rebind is already in flight — flag a follow-up so the loop in
// [_rebind] picks up the new active id once the current pass settles.
// Otherwise the switch is silently dropped (the early-return on
+14 -1
View File
@@ -42,7 +42,13 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
static const int continueWatchingPreviewLimit = 20;
static const int _continueWatchingProbeLimit = continueWatchingPreviewLimit + 1;
DiscoverProvider(this._multiServer, this._hiddenLibraries, this._libraries, {required this.isProfileBinding}) {
DiscoverProvider(
this._multiServer,
this._hiddenLibraries,
this._libraries, {
required this.isProfileBinding,
Future<void> Function(List<MediaItem>)? syncSystemShelf,
}) : _syncSystemShelfOverride = syncSystemShelf {
_loadCoordinator = CoalescedLoadCoordinator<String>(onFull: _loadOnce, onDelta: _loadDeltaOnce);
// Late server connects (reconnect after outage, slow wave) refresh
// discover the same way they refresh libraries. Removed in [dispose] so a
@@ -79,6 +85,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// instead of flashing the empty placeholder (main_screen primes another
/// load once binding settles).
final bool Function() isProfileBinding;
final Future<void> Function(List<MediaItem>)? _syncSystemShelfOverride;
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
StreamSubscription<DeletionEvent>? _deletionSubscription;
@@ -499,6 +506,7 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (viewOffset != null && index != -1 && _onDeck[index].viewOffsetMs != viewOffset) {
_onDeck = List.of(_onDeck)..[index] = _onDeck[index].copyWith(viewOffsetMs: viewOffset);
safeNotifyListeners();
unawaited(_syncSystemShelf(_onDeck));
}
return;
}
@@ -629,6 +637,11 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (isDisposed) return;
try {
final syncOverride = _syncSystemShelfOverride;
if (syncOverride != null) {
await syncOverride(onDeck);
continue;
}
final settings = await SettingsService.getInstance();
if (isDisposed) return;
final syncableOnDeck = onDeck
+1 -1
View File
@@ -165,7 +165,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
}) async {
final activeScope = _downloadManager.activeClientScopeIdForServer(ServerId(serverId));
if (activeScope != null && activeScope.isNotEmpty) return activeScope;
for (final globalKey in downloads.keys) {
for (final globalKey in downloads.keys.toList(growable: false)) {
if (!ownsDownloadKey(globalKey)) continue;
final parsed = parseGlobalKey(globalKey);
if (parsed?.serverId != serverId) continue;
+13 -10
View File
@@ -292,6 +292,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
@visibleForTesting
Future<void> debugHydrateOfflineWatchOverlay() => _applyOfflineWatchOverlay();
/// Load all persisted downloads and metadata from the database/cache
Future<void> _loadPersistedDownloads() async {
try {
@@ -901,24 +904,23 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final globalKey = metadata.globalKey;
final config = versionConfig ?? DownloadVersionConfig();
// Check if downloads are blocked on cellular
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (!_queueing.add(globalKey)) return 0;
safeNotifyListeners();
try {
// Mark as queueing to show loading state in UI
_queueing.add(globalKey);
safeNotifyListeners();
// Claim the operation before the first await so a second tap cannot
// launch a duplicate container expansion.
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
throw CellularDownloadBlockedException();
}
if (metadata.isMovie || metadata.isEpisode || metadata.kind == MediaKind.track) {
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
return queued ? 1 : 0;
} else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) {
return _withStashedMetadata(metadata, () => _queueMusicContainerDownload(metadata, client));
return await _withStashedMetadata(metadata, () => _queueMusicContainerDownload(metadata, client));
} else if (metadata.isShow || metadata.isSeason) {
return _withStashedMetadata(
return await _withStashedMetadata(
metadata,
() => _expandAndQueue(
container: metadata,
@@ -1360,6 +1362,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
try {
for (final entry in descendants) {
await _deleteDownload(entry.key, notify: false);
DeletionNotifier().notifyDeletedItem(item: entry.value, isDownloadOnly: true);
}
} finally {
_batchDeletionDepth--;
@@ -0,0 +1,11 @@
import 'package:flutter/widgets.dart';
enum LiveTvRefreshLifecycleTransition { pause, resume, ignore }
LiveTvRefreshLifecycleTransition liveTvRefreshTransition(AppLifecycleState state) {
return switch (state) {
AppLifecycleState.paused || AppLifecycleState.hidden => LiveTvRefreshLifecycleTransition.pause,
AppLifecycleState.resumed => LiveTvRefreshLifecycleTransition.resume,
AppLifecycleState.inactive || AppLifecycleState.detached => LiveTvRefreshLifecycleTransition.ignore,
};
}
+68 -39
View File
@@ -21,6 +21,7 @@ import '../../widgets/settings_builder.dart';
import '../../utils/app_logger.dart';
import '../../utils/desktop_window_padding.dart';
import '../../utils/platform_detector.dart';
import '../../utils/serial_future_queue.dart';
import '../../utils/snackbar_helper.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/focusable_tab_chip.dart';
@@ -74,7 +75,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {};
Future<void>? _channelsLoadFuture;
int _favoritesLoadGeneration = 0;
int _favoritesMutationGeneration = 0;
Future<void>? _favoritesLoadFuture;
final SerialFutureQueue _favoritesMutationQueue = SerialFutureQueue();
bool _favoritesLoaded = false;
List<LiveTvChannel> get _filteredChannels => filterLiveTvChannelsForFavorites(
channels: _channels,
@@ -402,7 +405,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_refreshVisibleTabs(multiServer);
// Load favorites by backend store: Plex is cloud/account-scoped, Jellyfin per server.
unawaited(_loadFavorites(multiServer));
final favoritesLoad = _loadFavorites(multiServer);
_favoritesLoadFuture = favoritesLoad;
unawaited(
favoritesLoad.whenComplete(() {
if (identical(_favoritesLoadFuture, favoritesLoad)) {
_favoritesLoadFuture = null;
}
}),
);
if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -422,7 +433,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Future<void> _loadFavorites(MultiServerProvider multiServer) async {
final loadGeneration = ++_favoritesLoadGeneration;
final mutationGeneration = _favoritesMutationGeneration;
_favoritesLoaded = false;
try {
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer);
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
@@ -450,11 +461,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}
}
if (!mounted ||
loadGeneration != _favoritesLoadGeneration ||
mutationGeneration != _favoritesMutationGeneration) {
return;
}
if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
setState(() {
_favoriteSourceByLiveServer
..clear()
@@ -471,6 +478,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_favoriteChannels = merged;
_refreshFavoriteKeys();
});
_favoritesLoaded = true;
appLogger.d('Live TV: loaded ${merged.length} favorite channels');
} catch (e) {
appLogger.e('Failed to load favorite channels', error: e);
@@ -484,23 +492,42 @@ class _LiveTvScreenState extends State<LiveTvScreen>
}
void _toggleFavorite(LiveTvChannel channel) {
++_favoritesMutationGeneration;
final source = _sourceForChannel(channel);
final favoriteKey = favoriteChannelKey(source, channel.key);
final scopeKey = liveTvChannelScopeKey(channel);
final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey];
if (storeKey != null) _favoriteStoreBySource[source] = storeKey;
_enqueueFavoriteMutation(() {
final source = _sourceForChannel(channel);
final favoriteKey = favoriteChannelKey(source, channel.key);
final scopeKey = liveTvChannelScopeKey(channel);
final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey];
if (storeKey != null) _favoriteStoreBySource[source] = storeKey;
setState(() {
if (_favoriteKeys.contains(favoriteKey)) {
_favoriteChannels = _favoriteChannels.where((f) => f.id != channel.key || f.source != source).toList();
} else {
_favoriteChannels = [..._favoriteChannels, FavoriteChannel.fromLiveTvChannel(channel, source)];
}
_refreshFavoriteKeys();
setState(() {
if (_favoriteKeys.contains(favoriteKey)) {
_favoriteChannels = _favoriteChannels.where((f) => f.id != channel.key || f.source != source).toList();
} else {
_favoriteChannels = [..._favoriteChannels, FavoriteChannel.fromLiveTvChannel(channel, source)];
}
_refreshFavoriteKeys();
});
});
}
_persistFavorites();
void _enqueueFavoriteMutation(VoidCallback mutation) {
final pendingLoad = _favoritesLoadFuture;
unawaited(
_favoritesMutationQueue
.run(() async {
if (pendingLoad != null) await pendingLoad;
if (!mounted) return;
if (!_favoritesLoaded) {
showErrorSnackBar(context, t.liveTv.favoritesLoadFailed);
return;
}
mutation();
await _persistFavorites();
})
.catchError((Object error, StackTrace stackTrace) {
appLogger.e('Failed to mutate favorite channels', error: error, stackTrace: stackTrace);
}),
);
}
void _showReorderFavorites() {
@@ -512,34 +539,35 @@ class _LiveTvScreenState extends State<LiveTvScreen>
favorites: List.from(_favoriteChannels),
channelMap: channelMap,
onReorder: (reordered) {
++_favoritesMutationGeneration;
setState(() {
_favoriteChannels = reordered;
_refreshFavoriteKeys();
_enqueueFavoriteMutation(() {
setState(() {
_favoriteChannels = reordered;
_refreshFavoriteKeys();
});
});
_persistFavorites();
},
onRemove: (removed) {
++_favoritesMutationGeneration;
setState(() {
_favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList();
_refreshFavoriteKeys();
_enqueueFavoriteMutation(() {
setState(() {
_favoriteChannels = _favoriteChannels.where((f) => f.stableKey != removed.stableKey).toList();
_refreshFavoriteKeys();
});
});
_persistFavorites();
},
),
);
}
void _persistFavorites() {
Future<void> _persistFavorites() async {
final multiServer = context.read<MultiServerProvider>();
final byStore = <String, List<FavoriteChannel>>{};
for (final f in _favoriteChannels) {
final storeKey = _favoriteStoreBySource[f.source];
for (final favorite in _favoriteChannels) {
final storeKey = _favoriteStoreBySource[favorite.source];
if (storeKey == null) continue;
byStore.putIfAbsent(storeKey, () => []).add(f);
byStore.putIfAbsent(storeKey, () => []).add(favorite);
}
final writtenStores = <String>{};
final writes = <Future<void>>[];
for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue;
@@ -548,14 +576,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
if (storeKey == null || !writtenStores.add(storeKey)) continue;
final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode;
final source = _favoriteSourceByLiveServer[liveServerKey];
if (source == null) continue; // not yet resolved — skip; next toggle will catch up
if (source == null) continue;
final channels = switch (mode) {
FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const <FavoriteChannel>[],
FavoriteChannelPersistenceMode.serverSlice =>
(byStore[storeKey] ?? const <FavoriteChannel>[]).where((f) => f.source == source).toList(),
(byStore[storeKey] ?? const <FavoriteChannel>[]).where((favorite) => favorite.source == source).toList(),
};
unawaited(client.liveTv.setFavoriteChannels(channels));
writes.add(client.liveTv.setFavoriteChannels(channels));
}
await Future.wait(writes);
}
void _focusCurrentTab() {
+5 -6
View File
@@ -20,6 +20,7 @@ import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/app_logger.dart';
import '../live_tv_refresh_lifecycle.dart';
import '../../../utils/formatters.dart';
import '../../../utils/live_tv_grouping.dart';
import '../../../utils/live_tv_matching.dart';
@@ -195,14 +196,12 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin, WidgetsBi
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
switch (liveTvRefreshTransition(state)) {
case LiveTvRefreshLifecycleTransition.pause:
pauseRefresh();
case AppLifecycleState.resumed:
case LiveTvRefreshLifecycleTransition.resume:
if (_isGuideVisible) resumeRefresh();
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
case LiveTvRefreshLifecycleTransition.ignore:
break;
}
}
+15 -6
View File
@@ -20,6 +20,7 @@ import '../../../utils/formatters.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../widgets/settings_section.dart';
import '../live_tv_refresh_lifecycle.dart';
import '../livetv_recording_actions.dart';
import '../livetv_styles.dart';
@@ -67,7 +68,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
bool _pendingFocus = false;
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appResumed = true;
bool _appRefreshActive = true;
final _firstTileFocusNode = FocusNode(debugLabel: 'recordings_tab_first_tile');
@override
@@ -88,10 +89,18 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final resumed = state == AppLifecycleState.resumed;
if (resumed == _appResumed) return;
_appResumed = resumed;
_syncRefreshTimer();
switch (liveTvRefreshTransition(state)) {
case LiveTvRefreshLifecycleTransition.pause:
if (!_appRefreshActive) return;
_appRefreshActive = false;
_syncRefreshTimer();
case LiveTvRefreshLifecycleTransition.resume:
if (_appRefreshActive) return;
_appRefreshActive = true;
_syncRefreshTimer();
case LiveTvRefreshLifecycleTransition.ignore:
break;
}
}
@override
@@ -126,7 +135,7 @@ class RecordingsTabState extends State<RecordingsTab> with WidgetsBindingObserve
void _syncRefreshTimer({bool reload = false}) {
_refreshTimer?.cancel();
_refreshTimer = null;
if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return;
if (!_refreshRequested || !_tickerEnabled || !_appRefreshActive || !mounted) return;
_refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) => _load());
if (reload) unawaited(_load());
}
+15 -6
View File
@@ -20,6 +20,7 @@ import '../../../widgets/hub_section.dart';
import '../../../widgets/overlay_sheet.dart';
import '../live_tv_actions_mixin.dart';
import '../live_tv_show_schedule_screen.dart';
import '../live_tv_refresh_lifecycle.dart';
class WhatsOnTab extends StatefulWidget {
final List<LiveTvChannel> channels;
@@ -41,7 +42,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
List<GlobalKey<HubSectionState>> _hubKeys = [];
bool _refreshRequested = true;
bool _tickerEnabled = false;
bool _appResumed = true;
bool _appRefreshActive = true;
@override
List<LiveTvChannel> get liveTvChannels => widget.channels;
@@ -64,10 +65,18 @@ class WhatsOnTabState extends State<WhatsOnTab>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final resumed = state == AppLifecycleState.resumed;
if (resumed == _appResumed) return;
_appResumed = resumed;
_syncRefreshTimer();
switch (liveTvRefreshTransition(state)) {
case LiveTvRefreshLifecycleTransition.pause:
if (!_appRefreshActive) return;
_appRefreshActive = false;
_syncRefreshTimer();
case LiveTvRefreshLifecycleTransition.resume:
if (_appRefreshActive) return;
_appRefreshActive = true;
_syncRefreshTimer();
case LiveTvRefreshLifecycleTransition.ignore:
break;
}
}
void pauseRefresh() {
@@ -83,7 +92,7 @@ class WhatsOnTabState extends State<WhatsOnTab>
void _syncRefreshTimer() {
_refreshTimer?.cancel();
_refreshTimer = null;
if (!_refreshRequested || !_tickerEnabled || !_appResumed || !mounted) return;
if (!_refreshRequested || !_tickerEnabled || !_appRefreshActive || !mounted) return;
_refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) => _loadHubs());
}
@@ -16,10 +16,13 @@ Future<LiveStreamRetryResult> runLiveStreamRetry<Session>({
required bool Function() isCurrent,
required void Function(Session session) adoptSession,
required void Function(Object error, StackTrace stackTrace) reportFailure,
required void Function(Session session) discardSession,
required void Function() onFinished,
}) async {
Session? recovered;
var adopted = false;
try {
final recovered = await recover();
recovered = await recover();
if (!isCurrent()) return LiveStreamRetryResult.stale;
if (recovered == null) throw StateError('Live stream recovery returned no session');
@@ -34,12 +37,14 @@ Future<LiveStreamRetryResult> runLiveStreamRetry<Session>({
if (!isCurrent()) return LiveStreamRetryResult.stale;
adoptSession(recovered);
adopted = true;
return LiveStreamRetryResult.succeeded;
} catch (error, stackTrace) {
if (!isCurrent()) return LiveStreamRetryResult.stale;
reportFailure(error, stackTrace);
return LiveStreamRetryResult.failed;
} finally {
if (recovered != null && !adopted) discardSession(recovered);
onFinished();
}
}
+2 -2
View File
@@ -29,12 +29,12 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
if (_live.fallbackLevel < 2) {
_live.fallbackLevel++;
_live.retrying = true;
appLogger.w('Live stream failed, retrying with fallback level $_live.fallbackLevel');
appLogger.w('Live stream failed, retrying with fallback level ${_live.fallbackLevel}');
unawaited(_retryLiveStream());
return;
}
if (_live.retryFailed) {
showGlobalErrorSnackBar(t.messages.streamInterrupted);
showGlobalErrorSnackBar(t.messages.liveStreamInterrupted);
return;
}
}
+2 -1
View File
@@ -123,10 +123,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
_live.adoptSession(recovered);
_live.markStreamRestartedAtLiveEdge();
},
discardSession: _abandonLiveSession,
reportFailure: (error, stackTrace) {
appLogger.e('Failed to recover live stream', error: error, stackTrace: stackTrace);
_live.retryFailed = true;
showGlobalErrorSnackBar(t.messages.streamInterrupted);
showGlobalErrorSnackBar(t.messages.liveStreamInterrupted);
},
onFinished: () {
if (isCurrent()) _live.retrying = false;
@@ -10,6 +10,7 @@ import '../../i18n/strings.g.dart';
import '../../models/companion_remote/remote_command.dart';
import '../../models/companion_remote/remote_session.dart';
import '../../utils/app_logger.dart';
import '../../utils/serial_future_queue.dart';
import '../base_peer_service.dart';
import 'remote_auth_context.dart';
import 'remote_auth_service.dart';
@@ -29,6 +30,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
// Client-side (remote) fields
IOWebSocketChannel? _channel;
StreamSubscription<dynamic>? _clientSocketSubscription;
StreamSubscription<dynamic>? _channelSubscription;
String? _myPeerId;
String? _hostAddress; // Format: "ip:port"
@@ -223,7 +226,8 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
});
socket.listen(
late final StreamSubscription<dynamic> socketSubscription;
socketSubscription = socket.listen(
(data) async {
try {
if (!isAuthenticated) {
@@ -313,6 +317,15 @@ class CompanionRemotePeerService with KeepaliveMixin {
unawaited(_clientSocket!.close(4004, 'Replaced by new connection'));
}
final previousSubscription = _clientSocketSubscription;
if (previousSubscription != null) {
try {
await previousSubscription.cancel();
} catch (e) {
appLogger.d('CompanionRemote: previous client listener cancel ignored', error: e);
}
}
_clientSocketSubscription = socketSubscription;
_clientSocket = socket;
_sessionEncKey = sessionEncKey;
_sendCounter = 0;
@@ -350,6 +363,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
},
onDone: () {
unawaited(socketSubscription.cancel());
authTimeout?.cancel();
appLogger.d('CompanionRemote: WebSocket connection closed');
// A replaced client's socket closes AFTER the new client already took
@@ -357,6 +371,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
// tear it down, or we'd clobber the live connection.
if (isAuthenticated && identical(_clientSocket, socket)) {
_clientSocket = null;
_clientSocketSubscription = null;
_sessionEncKey = null;
_isAuthenticated = false;
_selectedAuthContextId = null;
@@ -429,7 +444,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
List<int>? clientNonce;
String? receivedHostClientId;
_channel!.stream.listen(
_channelSubscription = _channel!.stream.listen(
(data) async {
try {
if (_isAuthenticated) {
@@ -596,6 +611,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
_deviceDisconnectedController.add(null);
_connectionStateController.add(RemoteSessionStatus.disconnected);
_isAuthenticated = false;
_channelSubscription = null;
_sessionEncKey = null;
_selectedAuthContextId = null;
_selectedHostClientId = null;
@@ -770,12 +786,12 @@ class CompanionRemotePeerService with KeepaliveMixin {
// Serialize cryptographic operations so implicit nonce counters cannot
// interleave when stream callbacks overlap.
Future<void>? _encryptChain;
Future<void>? _decryptChain;
Future<void>? _sendChain;
final SerialFutureQueue _encryptQueue = SerialFutureQueue();
final SerialFutureQueue _decryptQueue = SerialFutureQueue();
final SerialFutureQueue _sendQueue = SerialFutureQueue();
Future<List<int>> _encryptOutgoing(String plaintext) {
final result = (_encryptChain ?? Future<void>.value()).then((_) async {
return _encryptQueue.run(() async {
final encrypted = await RemoteAuthService.instance.encrypt(
_sessionEncKey!,
utf8.encode(plaintext),
@@ -785,8 +801,6 @@ class CompanionRemotePeerService with KeepaliveMixin {
_sendCounter++;
return encrypted;
});
_encryptChain = result.then<void>((_) {});
return result;
}
Future<void> _sendEncryptedToSocket(WebSocket socket, String plaintext) async {
@@ -797,9 +811,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
Future<String?> _decryptIncoming(dynamic data) {
if (_sessionEncKey == null) return Future<String?>.value();
final result = (_decryptChain ?? Future<void>.value()).then((_) => _decryptIncomingNow(data));
_decryptChain = result.then<void>((_) {});
return result;
return _decryptQueue.run(() => _decryptIncomingNow(data));
}
Future<String?> _decryptIncomingNow(dynamic data) async {
@@ -881,7 +893,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
}
// Chain sends to prevent counter interleaving from concurrent async encrypts
_sendChain = (_sendChain ?? Future.value()).then((_) async {
_sendQueue.run(() async {
try {
final json = jsonEncode(command.toJson());
final encrypted = await _encryptOutgoing(json);
@@ -905,56 +917,64 @@ class CompanionRemotePeerService with KeepaliveMixin {
});
}
Future<void> _runDisconnectCleanup(Future<dynamic>? operation, String name) async {
if (operation == null) return;
try {
await operation;
} catch (e) {
appLogger.d('CompanionRemote: $name cleanup ignored', error: e);
}
}
Future<void> disconnect() async {
appLogger.d('CompanionRemote: Disconnecting');
_isAuthenticated = false;
stopKeepalive();
// Flush commands already decoded by the overlapping stream callbacks
// before closing their transport, then reject any later send request.
await _decryptChain;
await _sendChain;
await _encryptChain;
_isAuthenticated = false;
final clientSocket = _clientSocket;
final channel = _channel;
final server = _server;
if (_clientSocket != null) {
try {
await _clientSocket!.close();
} catch (e) {
appLogger.d('CompanionRemote: client socket close ignored', error: e);
}
_server = null;
try {
// Stop inbound callbacks before taking queue snapshots. New sends are
// already rejected by `_isAuthenticated = false`.
await _runDisconnectCleanup(_clientSocketSubscription?.cancel(), 'client listener');
_clientSocketSubscription = null;
await _runDisconnectCleanup(_channelSubscription?.cancel(), 'channel listener');
_channelSubscription = null;
final serverClose = server?.close(force: true);
// Decrypted commands may enqueue acknowledgements, and sends enqueue
// encryption, so drain in dependency order.
await _decryptQueue.settled;
await _sendQueue.settled;
await _encryptQueue.settled;
await _runDisconnectCleanup(clientSocket?.close(), 'client socket');
await _runDisconnectCleanup(channel?.sink.close(), 'channel');
await _runDisconnectCleanup(serverClose, 'server');
} finally {
_clientSocket = null;
}
if (_channel != null) {
try {
await _channel!.sink.close();
} catch (e) {
appLogger.d('CompanionRemote: channel close ignored', error: e);
}
_channel = null;
_myPeerId = null;
_hostAddress = null;
_role = null;
_selectedAuthContextId = null;
_selectedHostClientId = null;
_sessionEncKey = null;
_sendCounter = 0;
_recvCounter = 0;
_sendQueue.reset();
_encryptQueue.reset();
_decryptQueue.reset();
_failedAuthAttempts.clear();
_authLockouts.clear();
_connectionStateController.add(RemoteSessionStatus.disconnected);
}
if (_server != null) {
await _server!.close();
_server = null;
}
_myPeerId = null;
_hostAddress = null;
_role = null;
_selectedAuthContextId = null;
_selectedHostClientId = null;
_sessionEncKey = null;
_sendCounter = 0;
_recvCounter = 0;
_sendChain = null;
_encryptChain = null;
_decryptChain = null;
_failedAuthAttempts.clear();
_authLockouts.clear();
_connectionStateController.add(RemoteSessionStatus.disconnected);
}
/// Whether the HTTP server is currently running.
+19 -4
View File
@@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import '../database/app_database.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import '../utils/isolate_helper.dart';
import 'api_cache.dart';
@@ -116,10 +117,11 @@ class JellyfinApiCache extends ApiCache {
}
}
/// Persist a watched/unwatched flip into every cached `BaseItemDto` row
/// for [itemId] (one per cached userId). Mirrors what the server returns
/// after the flip so a later cache reload reflects the current watched
/// state without a network roundtrip.
/// Persist a watched/unwatched flip into cached `BaseItemDto` rows for
/// [itemId]. Compound Jellyfin scope ids update only their user. A legacy
/// bare machine id is accepted only when its matching rows belong to one
/// user; ambiguous multi-user writes are skipped rather than bleeding watch
/// state across profiles.
///
/// [viewOffsetMs] is converted to Jellyfin's 100-ns ticks for
/// `UserData.PlaybackPositionTicks`. [lastViewedAt] is treated as Plex's
@@ -141,6 +143,19 @@ class JellyfinApiCache extends ApiCache {
..where((t) => _resolver.itemKeyPredicate(t.cacheKey, serverId, itemId));
final rows = await query.get();
if (rows.isEmpty) return;
if (!serverId.contains('/')) {
final userIds = <String>{
for (final row in rows)
if (JellyfinCacheResolver.parseItemKey(row.cacheKey) case final key?) key.userId,
};
if (userIds.length > 1) {
appLogger.w(
'Skipping ambiguous bare-scope Jellyfin watch-state cache write',
error: {'serverId': serverId, 'itemId': itemId, 'userCount': userIds.length},
);
return;
}
}
for (final row in rows) {
try {
final data = jsonDecode(row.data) as Map<String, dynamic>;
+29 -10
View File
@@ -74,22 +74,41 @@ class JellyfinCacheResolver {
..where((t) => t.pinned.equals(true))
..orderBy([(t) => OrderingTerm.asc(t.cacheKey)]))
.get();
if (rows.isEmpty) return const [];
final connections = await (database.select(database.connections)..where((t) => t.kind.equals('jellyfin'))).get();
final connectionById = {for (final connection in connections) connection.id: connection};
final bindings = await database.select(database.profileConnections).get();
final bindingsByConnection = <String, List<ProfileConnectionRow>>{};
for (final binding in bindings) {
bindingsByConnection.putIfAbsent(binding.connectionId, () => []).add(binding);
}
bool matchesBinding(String connectionId, String userId) {
final connectionBindings = bindingsByConnection[connectionId];
return connectionBindings == null ||
connectionBindings.isEmpty ||
connectionBindings.any((binding) => binding.userIdentifier == userId);
}
final matches = <ResolvedJellyfinCacheItem>[];
for (final row in rows) {
final resolved = await _resolveRow(row);
if (resolved != null) matches.add(resolved);
final key = parseItemKey(row.cacheKey);
if (key == null) continue;
final compoundId = '${key.machineId}/${key.userId}';
final compound = connectionById[compoundId];
if (compound != null && matchesBinding(compound.id, key.userId)) {
matches.add((cacheRow: row, connection: compound, key: key));
continue;
}
final legacy = connectionById[key.machineId];
if (legacy != null && matchesBinding(legacy.id, key.userId)) {
matches.add((cacheRow: row, connection: legacy, key: key));
}
}
return matches;
}
Future<ResolvedJellyfinCacheItem?> _resolveRow(ApiCacheData row) async {
final key = parseItemKey(row.cacheKey);
if (key == null) return null;
final connection = await findConnection(key.scopeId, userId: key.userId);
if (connection == null) return null;
return (cacheRow: row, connection: connection, key: key);
}
Future<ConnectionRow?> findConnection(String serverOrScopeId, {String? userId}) async {
final scope = _splitScope(serverOrScopeId);
if (scope.userId != null && userId != null && scope.userId != userId) return null;
+18 -10
View File
@@ -90,7 +90,8 @@ class MultiServerManager {
/// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
Future<void>? _activeReconnect;
int _profileRefreshGeneration = 0;
int _profileRefreshEpoch = 0;
final Map<String, int> _profileRefreshGenerations = {};
/// Debounce timer for connectivity events — collapses rapid network flapping
Timer? _connectivityDebounce;
@@ -500,8 +501,15 @@ class MultiServerManager {
PlexAccountConnection connection, {
Duration timeout = MediaServerTimeouts.perServerConnect,
}) async {
final generation = ++_profileRefreshGeneration;
if (connection.servers.isEmpty) return const {};
final accountId = connection.id;
final epoch = _profileRefreshEpoch;
final generation = (_profileRefreshGenerations[accountId] ?? 0) + 1;
_profileRefreshGenerations[accountId] = generation;
bool isStale() => epoch != _profileRefreshEpoch || _profileRefreshGenerations[accountId] != generation;
if (connection.servers.isEmpty) {
if (!isStale()) _profileRefreshGenerations.remove(accountId);
return const {};
}
final bound = <String>{};
final futures = connection.servers.map((server) async {
final serverId = server.clientIdentifier;
@@ -510,9 +518,7 @@ class MultiServerManager {
final existing = _clients[serverId];
if (existing is PlexClient && ((_serverStatus[serverId] ?? false) || _authErrorServers.contains(serverId))) {
await existing.applyTokenUpdate(server.accessToken);
if (generation != _profileRefreshGeneration ||
!identical(_plexServers[serverId], server) ||
!identical(_clients[serverId], existing)) {
if (isStale() || !identical(_plexServers[serverId], server) || !identical(_clients[serverId], existing)) {
return;
}
_authErrorServers.remove(serverId);
@@ -526,7 +532,7 @@ class MultiServerManager {
server: server,
clientIdentifier: connection.clientIdentifier,
).namedTimeout(timeout, operation: 'connect to ${server.name}');
if (generation != _profileRefreshGeneration || !identical(_plexServers[serverId], server)) {
if (isStale() || !identical(_plexServers[serverId], server)) {
_closeClient(client);
return;
}
@@ -538,18 +544,19 @@ class MultiServerManager {
bound.add(serverId);
_connectProgressController.add((serverId: serverId, online: true));
} catch (e, stackTrace) {
if (generation != _profileRefreshGeneration || !identical(_plexServers[serverId], server)) return;
if (isStale() || !identical(_plexServers[serverId], server)) return;
appLogger.e('refreshTokensForProfile: failed to connect ${server.name}', error: e, stackTrace: stackTrace);
_serverStatus[serverId] = false;
_connectProgressController.add((serverId: serverId, online: false));
}
});
await Future.wait(futures);
if (generation != _profileRefreshGeneration) return const {};
if (isStale()) return const {};
_statusController.add(Map.from(_serverStatus));
if (bound.isNotEmpty && _connectivitySubscription == null) {
_startNetworkMonitoring();
}
_profileRefreshGenerations.remove(accountId);
return bound;
}
@@ -1171,7 +1178,8 @@ class MultiServerManager {
}
Set<MediaServerClient> _detachAllClients() {
++_profileRefreshGeneration;
++_profileRefreshEpoch;
_profileRefreshGenerations.clear();
_stopNetworkMonitoring();
for (final timer in _reconnectDebounce.values) {
timer.cancel();
+10 -5
View File
@@ -15,6 +15,15 @@ import 'dialogs.dart';
import 'download_version_utils.dart';
import 'snackbar_helper.dart';
@visibleForTesting
String? validateEpisodeCountInput(String text, {required bool allowZero}) {
final count = int.tryParse(text);
if (count == null || count < 0 || (!allowZero && count == 0)) {
return t.downloads.invalidEpisodeCount;
}
return null;
}
/// Dialog option for the download picker. Typed to avoid stringly-typed values.
enum _DownloadChoice { all, unwatched, next5, next10, custom, delete }
@@ -313,11 +322,7 @@ Future<int?> _showEpisodeCountDialog(
confirmText: t.common.ok,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (text) {
final n = int.tryParse(text);
if (n == null || n < 0 || (!allowZero && n == 0)) return '';
return null;
},
validator: (text) => validateEpisodeCountInput(text, allowZero: allowZero),
);
if (result == null) return null;
return int.tryParse(result);
+26
View File
@@ -0,0 +1,26 @@
/// Runs asynchronous operations one at a time without allowing a failed
/// operation to poison the queue tail.
class SerialFutureQueue {
Future<void> _tail = Future<void>.value();
Future<T> run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = _settle(result);
return result;
}
Future<void> get settled => _tail;
void reset() {
_tail = Future<void>.value();
}
static Future<void> _settle<T>(Future<T> operation) async {
try {
await operation;
} catch (_) {
// The returned operation future carries the error to its caller. The
// internal tail only represents when the queue may start its next item.
}
}
}
+30 -28
View File
@@ -1,9 +1,10 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../i18n/strings.g.dart';
import '../providers/download_provider.dart';
import '../widgets/deletion_progress_dialog.dart';
import 'dialogs.dart';
class SmartDeletionHandler {
/// Execute deletion with smart progress dialog
@@ -15,11 +16,11 @@ class SmartDeletionHandler {
int delayMs = 500,
}) async {
bool deletionComplete = false;
final dialogKey = GlobalKey();
({NavigatorState navigator, DialogRoute<void> route})? progressDialog;
Future.delayed(Duration(milliseconds: delayMs), () {
final progressTimer = Timer(Duration(milliseconds: delayMs), () {
if (!deletionComplete && context.mounted) {
_showProgressDialog(context, provider, globalKey, dialogKey);
progressDialog = _showProgressDialog(context, globalKey);
}
});
@@ -27,39 +28,40 @@ class SmartDeletionHandler {
await provider.deleteDownload(globalKey);
} finally {
deletionComplete = true;
final dialogContext = dialogKey.currentContext;
if (dialogContext != null && dialogContext.mounted) {
final route = ModalRoute.of(dialogContext);
if (route != null && route.isActive) {
Navigator.of(dialogContext).removeRoute(route);
}
progressTimer.cancel();
final dialog = progressDialog;
if (dialog != null && dialog.navigator.mounted && dialog.route.isActive) {
dialog.navigator.removeRoute(dialog.route);
}
}
}
static void _showProgressDialog(BuildContext context, DownloadProvider _, String globalKey, GlobalKey dialogKey) {
showScopedDialog<void>(
static ({NavigatorState navigator, DialogRoute<void> route}) _showProgressDialog(
BuildContext context,
String globalKey,
) {
final navigator = Navigator.of(context);
final route = DialogRoute<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => KeyedSubtree(
key: dialogKey,
child: Consumer<DownloadProvider>(
builder: (context, provider, child) {
final progress = provider.getDeletionProgress(globalKey);
builder: (_) => Consumer<DownloadProvider>(
builder: (context, provider, child) {
final progress = provider.getDeletionProgress(globalKey);
if (progress == null) {
return AlertDialog(
content: Row(
mainAxisSize: .min,
children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)],
),
);
}
if (progress == null) {
return AlertDialog(
content: Row(
mainAxisSize: .min,
children: [const CircularProgressIndicator(), const SizedBox(width: 20), Text(t.downloads.deleting)],
),
);
}
return DeletionProgressDialog(progress: progress);
},
),
return DeletionProgressDialog(progress: progress);
},
),
);
navigator.push(route);
return (navigator: navigator, route: route);
}
}
@@ -16,10 +16,34 @@ import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart';
import '../../profiles/profile_connection_registry.dart';
import '../../providers/companion_remote_provider.dart';
import '../../services/base_peer_service.dart';
import '../../services/settings_service.dart';
import '../../utils/app_logger.dart';
import '../loading_indicator_box.dart';
@visibleForTesting
String companionRemotePairingErrorMessage(Object error) {
if (error is PeerError) {
return switch (error.type) {
PeerErrorType.timeout => t.companionRemote.pairing.connectionTimedOut,
PeerErrorType.connectionFailed || PeerErrorType.invalidSession => t.companionRemote.pairing.sessionNotFound,
PeerErrorType.authFailed => t.companionRemote.pairing.authFailed,
_ => error.message,
};
}
final message = error.toString().replaceFirst('Exception: ', '');
if (message.contains('timeout') || message.contains('Timed out')) {
return t.companionRemote.pairing.connectionTimedOut;
} else if (message.contains('Failed to connect')) {
return t.companionRemote.pairing.sessionNotFound;
} else if (message.contains('Authentication failed')) {
return t.companionRemote.pairing.authFailed;
}
return t.companionRemote.pairing.failedToConnect(error: message);
}
/// Discovers LAN hosts and provides UI to connect to them.
class DiscoveryView extends StatefulWidget {
const DiscoveryView({super.key});
@@ -158,7 +182,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
} catch (e) {
appLogger.e('Failed to connect', error: e);
if (!mounted) return;
setState(() => _errorMessage = _parseErrorMessage(e.toString()));
setState(() => _errorMessage = companionRemotePairingErrorMessage(e));
} finally {
setStateIfMounted(() => _isConnecting = false);
}
@@ -184,17 +208,6 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
);
}
String _parseErrorMessage(String error) {
if (error.contains('timeout') || error.contains('Timed out')) {
return t.companionRemote.pairing.connectionTimedOut;
} else if (error.contains('Failed to connect')) {
return t.companionRemote.pairing.sessionNotFound;
} else if (error.contains('Authentication failed')) {
return t.companionRemote.pairing.authFailed;
}
return t.companionRemote.pairing.failedToConnect(error: error.replaceAll('Exception: ', ''));
}
IconData _platformIcon(String platform) {
switch (platform.toLowerCase()) {
case 'macos':
+19 -9
View File
@@ -202,12 +202,15 @@ class OptimizedMediaImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final path = localFilePath;
if (path == null) return _buildResolved(context, null);
if (path == null) {
return _buildResolved(context, _LocalFileResolution.missing, null);
}
return _ResolvedLocalFile(path: path, builder: _buildResolved);
}
Widget _buildResolved(BuildContext context, File? localFile) {
final hasLocal = localFile != null;
Widget _buildResolved(BuildContext context, _LocalFileResolution resolution, File? localFile) {
if (resolution == _LocalFileResolution.pending) return _surfacePlaceholder(context);
final hasLocal = resolution == _LocalFileResolution.present;
if (!hasLocal && (imagePath == null || imagePath!.isEmpty)) {
return _buildFallback(context);
@@ -216,7 +219,7 @@ class OptimizedMediaImage extends StatelessWidget {
if (_hasKnownDimensions) {
return blurArtwork(
hasLocal
? _buildLocalFileImage(context, localFile, width!, height!)
? _buildLocalFileImage(context, localFile!, width!, height!)
: _buildCachedImage(context, width!, height!),
);
}
@@ -227,7 +230,7 @@ class OptimizedMediaImage extends StatelessWidget {
final effectiveWidth = _resolvedDimension(width, constraints.maxWidth, 300.0);
final effectiveHeight = _resolvedDimension(height, constraints.maxHeight, 450.0);
return hasLocal
? _buildLocalFileImage(context, localFile, effectiveWidth, effectiveHeight)
? _buildLocalFileImage(context, localFile!, effectiveWidth, effectiveHeight)
: _buildCachedImage(context, effectiveWidth, effectiveHeight);
},
),
@@ -513,11 +516,13 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic
}
}
enum _LocalFileResolution { pending, missing, present }
class _ResolvedLocalFile extends StatefulWidget {
const _ResolvedLocalFile({required this.path, required this.builder});
final String path;
final Widget Function(BuildContext context, File? file) builder;
final Widget Function(BuildContext context, _LocalFileResolution resolution, File? file) builder;
@override
State<_ResolvedLocalFile> createState() => _ResolvedLocalFileState();
@@ -525,6 +530,7 @@ class _ResolvedLocalFile extends StatefulWidget {
class _ResolvedLocalFileState extends State<_ResolvedLocalFile> {
File? _file;
_LocalFileResolution _resolution = _LocalFileResolution.pending;
int _generation = 0;
@override
@@ -536,16 +542,20 @@ class _ResolvedLocalFileState extends State<_ResolvedLocalFile> {
@override
void didUpdateWidget(_ResolvedLocalFile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.path != widget.path) _resolve();
if (oldWidget.path != widget.path || _resolution == _LocalFileResolution.missing) _resolve();
}
void _resolve() {
final generation = ++_generation;
_file = null;
_resolution = _LocalFileResolution.pending;
final candidate = File(widget.path);
candidate.exists().then((exists) {
if (!mounted || generation != _generation) return;
setState(() => _file = exists ? candidate : null);
setState(() {
_file = exists ? candidate : null;
_resolution = exists ? _LocalFileResolution.present : _LocalFileResolution.missing;
});
});
}
@@ -556,5 +566,5 @@ class _ResolvedLocalFileState extends State<_ResolvedLocalFile> {
}
@override
Widget build(BuildContext context) => widget.builder(context, _file);
Widget build(BuildContext context) => widget.builder(context, _resolution, _file);
}
@@ -248,12 +248,23 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
_desktopControlsKey.currentState?.hideContentStrip();
_cancelSkipButtonDismissTimer();
_setControlsState(() {
_controlsOpaque = false;
if (_currentMarker != null) _skipButtonDismissed = true;
});
_reclaimFocusAfterControlsHide();
} else {
} else if (visibilityChanged) {
_setControlsState(() {
if (controlsVisible) _controlsMounted = true;
_controlsMounted = true;
_controlsOpaque = false;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls || !_controlsMounted) return;
_setControlsState(() => _controlsOpaque = true);
});
} else if (controlsVisible && !_controlsMounted) {
_setControlsState(() {
_controlsMounted = true;
_controlsOpaque = true;
});
}
@@ -541,6 +541,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
late bool _lastControlsVisible;
late bool _controlsMounted;
late bool _controlsOpaque;
bool _isLoadingExtras = false;
// Item key the in-flight extras load belongs to, so a load for a swapped
// item can start while a stale one is still in flight (and the stale
@@ -658,6 +659,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
super.initState();
_lastControlsVisible = widget.chromeController.controlsVisible;
_controlsMounted = _lastControlsVisible;
_controlsOpaque = _lastControlsVisible;
_focusNode = FocusNode();
_skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton');
_seekThrottle = throttle(
@@ -750,6 +752,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
if (oldWidget.chromeController != widget.chromeController) {
oldWidget.chromeController.removeListener(_onChromeChanged);
_lastControlsVisible = widget.chromeController.controlsVisible;
_controlsMounted = _lastControlsVisible;
_controlsOpaque = _lastControlsVisible;
widget.chromeController.addListener(_onChromeChanged);
}
// The same controls instance survives in-place episode swaps — re-key
@@ -948,7 +952,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
// Prevent focus from entering controls when hidden
canRequestFocus: _showControls,
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
opacity: _controlsOpaque ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
onEnd: () {
if (!_showControls) {
+12
View File
@@ -80,6 +80,18 @@ else
FAILED=1
fi
# 4. Workflow and script regression guards
section "workflow and script guards"
if python3 scripts/check_build_workflow.py &&
python3 scripts/check_update_packages_workflow.py &&
python3 scripts/test_pubspec_version.py &&
python3 scripts/test_clean_translations.py; then
ok "workflow and script guards passed"
else
fail "workflow or script guard failed"
FAILED=1
fi
# 3. Native formatting
section "native format"
out="$(mktemp)"
+18
View File
@@ -73,6 +73,20 @@ void main() {
});
});
test('advance cleanup failure is contained after the transition', () async {
final core = _AudioCoreMock()..failPlaylistRemove0 = true;
await run(core, (player, transitions) async {
await openFirst(player);
await player.setNext(Media('https://example.test/t2.flac'));
player.handlePlayerEvent('file-loaded', null);
await Future<void>.delayed(Duration.zero);
expect(transitions, ['https://example.test/t2.flac']);
expect(core.commands('playlist-remove').last, ['playlist-remove', '0']);
});
});
test('open() still converts content:// (regression)', () async {
final core = _AudioCoreMock();
await run(core, (player, transitions) async {
@@ -269,6 +283,7 @@ class _AudioCoreMock {
int _nextFd = 7;
bool failOpenContentFd = false;
bool failPlaylistRemove1 = false;
bool failPlaylistRemove0 = false;
Future<Object?> handle(MethodCall call) async {
calls.add(call);
@@ -289,6 +304,9 @@ class _AudioCoreMock {
return null;
case 'command':
final args = (_args(call)['args'] as List).cast<Object?>();
if (failPlaylistRemove0 && args.length >= 2 && args[0] == 'playlist-remove' && args[1] == '0') {
throw PlatformException(code: 'error', message: 'playlist-remove failed');
}
if (failPlaylistRemove1 && args.length >= 2 && args[0] == 'playlist-remove' && args[1] == '1') {
throw PlatformException(code: 'error', message: 'playlist-remove failed');
}
@@ -789,6 +789,47 @@ void main() {
expect(activeProfile.isBinding, isFalse);
});
test('A to B to A during one pass forces a complete final A bind', () async {
binder.dispose();
multiServerProvider.dispose();
final gated = _GatedJellyfinManager();
manager = gated;
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
binder = ActiveProfileBinder(
activeProfile: activeProfile,
connections: connections,
profileConnections: profileConnections,
serverManager: manager,
multiServerProvider: multiServerProvider,
pinPrompt: (_, {String? errorMessage}) async => null,
shouldDeferInitialBind: (_) async => false,
);
final profileA = await createActiveLocalProfile('local-a');
final profileB = Profile.local(id: 'local-b', displayName: 'B', createdAt: DateTime(2026, 1, 2));
await profiles.upsert(profileB);
await pumpUntil(() async => activeProfile.profiles.any((profile) => profile.id == profileB.id));
final jellyfin = _jellyfinConnection();
await connections.upsert(jellyfin);
await profileConnections.upsert(
ProfileConnection(profileId: profileA.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId),
);
binder.start();
await pumpUntil(() async => gated.calls == 1);
expect(await activeProfile.activate(profileB), isTrue);
expect(await activeProfile.activate(profileA), isTrue);
gated.gate.complete();
await activeProfile.awaitBindingSettle();
expect(gated.calls, 2);
expect(binder.debugLastBoundProfileId, profileA.id);
expect(multiServerProvider.onlineServerIds, ['jf-machine']);
expect(activeProfile.lastBindingSucceeded, isTrue);
});
test('passive notifications do not retry a failed profile; explicit rebind does', () async {
binder.dispose();
multiServerProvider.dispose();
+13 -1
View File
@@ -151,6 +151,7 @@ void main() {
late HiddenLibrariesProvider hiddenLibraries;
late LibrariesProvider libraries;
late DiscoverProvider provider;
late List<List<MediaItem>> shelfSyncs;
bool isBinding = false;
setUp(() async {
@@ -158,6 +159,7 @@ void main() {
SettingsService.resetForTesting();
await SettingsService.getInstance();
isBinding = false;
shelfSyncs = [];
client = _FakeClient();
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
@@ -165,7 +167,13 @@ void main() {
multiServer = MultiServerProvider(manager, aggregation);
hiddenLibraries = HiddenLibrariesProvider();
libraries = LibrariesProvider();
provider = DiscoverProvider(multiServer, hiddenLibraries, libraries, isProfileBinding: () => isBinding);
provider = DiscoverProvider(
multiServer,
hiddenLibraries,
libraries,
isProfileBinding: () => isBinding,
syncSystemShelf: (items) async => shelfSyncs.add(List<MediaItem>.of(items)),
);
});
tearDown(() {
@@ -290,6 +298,8 @@ void main() {
aggregation.onDeckResult = () => [playing, for (var i = 2; i <= 21; i++) _item('ep-$i')];
aggregation.hubsResult = () => [_hub('hub-1')];
await provider.load();
await pumpEventQueue();
shelfSyncs.clear();
final onDeckCallsBefore = aggregation.onDeckCalls;
final hubCallsBefore = aggregation.hubCalls;
@@ -302,6 +312,8 @@ void main() {
expect(provider.hasMoreContinueWatching, isTrue);
expect(aggregation.onDeckCalls, onDeckCallsBefore);
expect(aggregation.hubCalls, hubCallsBefore);
expect(shelfSyncs, hasLength(1));
expect(shelfSyncs.single.first.viewOffsetMs, 30000);
});
test('watched-threshold progress refreshes continue watching only', () async {
+86 -1
View File
@@ -16,6 +16,7 @@ import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/deletion_notifier.dart';
import 'package:plezy/utils/watch_state_notifier.dart';
import '../test_helpers/media_items.dart';
@@ -37,14 +38,18 @@ class _ThrowingClient implements MediaServerClient {
/// Returns canned tracks from [fetchPlayableDescendants] (album/artist
/// expansion) and records the requested parent ids.
class _MusicExpansionClient implements MediaServerClient {
_MusicExpansionClient(this.tracks);
_MusicExpansionClient(this.tracks, {this.gate, this.started});
final List<MediaItem> tracks;
final Future<void>? gate;
final Completer<void>? started;
final fetchPlayableDescendantsCalls = <String>[];
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
fetchPlayableDescendantsCalls.add(parentId);
if (started != null && !started!.isCompleted) started!.complete();
if (gate != null) await gate;
return tracks;
}
@@ -630,6 +635,45 @@ void main() {
p.dispose();
});
test('container queue ownership remains claimed until expansion finishes', () async {
final album = testMediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
final track = testMediaItem(
id: 't1',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track',
parentId: album.id,
serverId: ServerId('srv'),
);
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
provider.debugSeedState(
downloads: {track.globalKey: DownloadProgress(globalKey: track.globalKey, status: DownloadStatus.completed)},
metadata: {track.globalKey: track},
ownedDownloadKeys: const {},
);
final started = Completer<void>();
final release = Completer<void>();
final client = _MusicExpansionClient([track], gate: release.future, started: started);
final first = provider.queueDownload(album, client);
await started.future;
expect(await provider.queueDownload(album, client), 0);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
release.complete();
expect(await first, 1);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
provider.dispose();
});
test('deleting an album emits one provider notification for all tracks', () async {
MediaItem track(String id) => testMediaItem(
id: id,
@@ -668,11 +712,16 @@ void main() {
);
var notifications = 0;
p.addListener(() => notifications++);
final deletionEvents = <DeletionEvent>[];
final deletionSubscription = DeletionNotifier().stream.listen(deletionEvents.add);
addTearDown(deletionSubscription.cancel);
await p.deleteDownload(album.globalKey);
await pumpEventQueue();
expect(notifications, 1);
expect(p.downloads, isEmpty);
expect(deletionEvents.map((event) => event.itemId), unorderedEquals(['t1', 't2', 'album-1']));
p.dispose();
});
@@ -1030,6 +1079,42 @@ void main() {
p.dispose();
});
test('offline watch hydration snapshots downloads before database awaits', () async {
final provider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await provider.ensureInitialized();
final item = testMediaItem(
id: '1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie',
serverId: ServerId('srv'),
viewCount: 0,
);
provider.debugSeedState(
downloads: {
'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed),
'srv:2': const DownloadProgress(globalKey: 'srv:2', status: DownloadStatus.completed),
},
metadata: {item.globalKey: item},
);
await db.insertWatchAction(
profileId: 'test-profile',
serverId: ServerId('srv'),
ratingKey: item.id,
actionType: 'watched',
);
scheduleMicrotask(() {
provider.debugSeedState(
downloads: {'srv:3': const DownloadProgress(globalKey: 'srv:3', status: DownloadStatus.completed)},
);
});
await provider.debugHydrateOfflineWatchOverlay();
expect(provider.getMetadata(item.globalKey)?.isWatched, isTrue);
provider.dispose();
});
test('profile switch discards an in-flight metadata refresh from the old scope', () async {
await insertJellyfinConnection('user-a');
await db.insertDownload(
@@ -0,0 +1,16 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/livetv/live_tv_refresh_lifecycle.dart';
void main() {
test('desktop inactive state leaves Live TV refresh timers running', () {
expect(liveTvRefreshTransition(AppLifecycleState.inactive), LiveTvRefreshLifecycleTransition.ignore);
expect(liveTvRefreshTransition(AppLifecycleState.detached), LiveTvRefreshLifecycleTransition.ignore);
});
test('only actual background states pause and resumed restarts', () {
expect(liveTvRefreshTransition(AppLifecycleState.hidden), LiveTvRefreshLifecycleTransition.pause);
expect(liveTvRefreshTransition(AppLifecycleState.paused), LiveTvRefreshLifecycleTransition.pause);
expect(liveTvRefreshTransition(AppLifecycleState.resumed), LiveTvRefreshLifecycleTransition.resume);
});
}
@@ -11,6 +11,7 @@ void main() {
expect(harness.failures, hasLength(1));
expect(harness.finished, isTrue);
expect(harness.adopted, isFalse);
expect(harness.discarded, isFalse);
});
test('reports stream URL lookup failures and finishes retry state', () async {
@@ -21,6 +22,7 @@ void main() {
expect(harness.failures, hasLength(1));
expect(harness.finished, isTrue);
expect(harness.adopted, isFalse);
expect(harness.discarded, isTrue);
});
test('reports player option failures and finishes retry state', () async {
@@ -31,6 +33,7 @@ void main() {
expect(harness.failures, hasLength(1));
expect(harness.finished, isTrue);
expect(harness.adopted, isFalse);
expect(harness.discarded, isTrue);
});
test('reports player open failures and finishes retry state', () async {
@@ -41,6 +44,7 @@ void main() {
expect(harness.failures, hasLength(1));
expect(harness.finished, isTrue);
expect(harness.adopted, isFalse);
expect(harness.discarded, isTrue);
});
test('adopts the recovered session after a successful open', () async {
@@ -52,6 +56,7 @@ void main() {
expect(harness.failures, isEmpty);
expect(harness.finished, isTrue);
expect(harness.adopted, isTrue);
expect(harness.discarded, isFalse);
});
test('stale operation does not report an async failure', () async {
@@ -63,6 +68,7 @@ void main() {
expect(harness.failures, isEmpty);
expect(harness.finished, isTrue);
expect(harness.adopted, isFalse);
expect(harness.discarded, isTrue);
});
});
}
@@ -79,6 +85,7 @@ class _RetryHarness {
bool current = true;
bool finished = false;
bool adopted = false;
bool discarded = false;
Future<LiveStreamRetryResult> run() => runLiveStreamRetry<Object>(
recover: () => _stage(_Stage.recover, Object.new),
@@ -88,6 +95,7 @@ class _RetryHarness {
isCurrent: () => current,
adoptSession: (_) => adopted = true,
reportFailure: (error, _) => failures.add(error),
discardSession: (_) => discarded = true,
onFinished: () => finished = true,
);
@@ -314,5 +314,45 @@ void main() {
expect((byKey['$machineId/user-a:/Users/user-a/Items/item-1']!['UserData'] as Map)['Played'], isTrue);
expect((byKey['$machineId/user-b:/Users/user-b/Items/item-1']!['UserData'] as Map)['Played'], isFalse);
});
test('bare Jellyfin server id updates the only cached user scope', () async {
const machineId = 'jf-machine';
await putItemRow(
serverId: ServerId(machineId),
userId: 'user-a',
itemId: 'item-1',
data: {
...jellyfinItem(id: 'item-1'),
'UserData': {'Played': false, 'PlayCount': 0},
},
);
await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true);
final row = await db.select(db.apiCache).getSingle();
expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isTrue);
});
test('bare Jellyfin server id skips ambiguous multi-user cache updates', () async {
const machineId = 'jf-machine';
for (final userId in ['user-a', 'user-b']) {
await putItemRow(
serverId: ServerId(machineId),
userId: userId,
itemId: 'item-1',
data: {
...jellyfinItem(id: 'item-1'),
'UserData': {'Played': false, 'PlayCount': 0},
},
);
}
await cache.applyWatchState(serverId: ServerId(machineId), itemId: 'item-1', isWatched: true);
final rows = await db.select(db.apiCache).get();
for (final row in rows) {
expect((jsonDecode(row.data)['UserData'] as Map)['Played'], isFalse);
}
});
});
}
@@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:drift/drift.dart' show Value;
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryInterceptor, Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
@@ -9,9 +9,11 @@ import 'package:plezy/services/jellyfin_cache_resolver.dart';
void main() {
late AppDatabase db;
late JellyfinCacheResolver resolver;
late _SelectCounter selectCounter;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
selectCounter = _SelectCounter();
db = AppDatabase.forTesting(NativeDatabase.memory().interceptWith(selectCounter));
resolver = JellyfinCacheResolver(db);
});
@@ -130,4 +132,32 @@ void main() {
expect(matches.map((match) => match.connection.id).toSet(), {'server/user-a', 'server/user-b'});
});
test('pinned resolution uses a constant number of selects as rows grow', () async {
await insertConnection('server', 'user-a', profileId: 'profile-a');
await insertItem('server/user-a', 'user-a', 'item-1', pinned: true);
selectCounter.count = 0;
expect(await resolver.findPinnedItems(), hasLength(1));
final oneRowSelects = selectCounter.count;
for (var i = 2; i <= 100; i++) {
await insertItem('server/user-a', 'user-a', 'item-$i', pinned: true);
}
selectCounter.count = 0;
expect(await resolver.findPinnedItems(), hasLength(100));
expect(selectCounter.count, oneRowSelects);
expect(oneRowSelects, 3);
});
}
class _SelectCounter extends QueryInterceptor {
int count = 0;
@override
Future<List<Map<String, Object?>>> runSelect(QueryExecutor executor, String statement, List<Object?> args) {
count++;
return executor.runSelect(statement, args);
}
}
@@ -195,6 +195,62 @@ void main() {
expect(m.authErrorServerIds, isNot(contains('server-1')));
expect(client.config.token, 'new-token');
});
test('concurrent Plex account refreshes do not invalidate each other', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
addTearDown(db.close);
final manager = MultiServerManager();
addTearDown(manager.dispose);
PlexClient client(String serverId) => PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://$serverId.example',
token: 'old-$serverId',
clientIdentifier: 'client-$serverId',
product: 'Plezy',
version: '1.0.0',
),
serverId: ServerId(serverId),
serverName: serverId,
httpClient: MockClient((_) async => http.Response('{}', 200)),
);
final clientA = client('server-a');
final clientB = client('server-b');
manager.debugRegisterClientForTesting(clientA, online: true);
manager.debugRegisterClientForTesting(clientB, online: true);
PlexAccountConnection account(String accountId, String serverId) => PlexAccountConnection(
id: accountId,
accountToken: 'account-token',
clientIdentifier: 'client-$serverId',
accountLabel: accountId,
servers: [
PlexServer(
name: serverId,
clientIdentifier: serverId,
accessToken: 'new-$serverId',
connections: const [],
owned: true,
),
],
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
final results = await Future.wait([
manager.refreshTokensForProfile(account('account-a', 'server-a')),
manager.refreshTokensForProfile(account('account-b', 'server-b')),
]);
expect(results, [
{'server-a'},
{'server-b'},
]);
expect(clientA.config.token, 'new-server-a');
expect(clientB.config.token, 'new-server-b');
});
});
group('Jellyfin connection updates', () {
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/utils/download_utils.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
test('episode count validation returns a localized message for invalid input', () {
expect(validateEpisodeCountInput('', allowZero: false), t.downloads.invalidEpisodeCount);
expect(validateEpisodeCountInput('0', allowZero: false), t.downloads.invalidEpisodeCount);
expect(validateEpisodeCountInput('not-a-number', allowZero: true), t.downloads.invalidEpisodeCount);
});
test('episode count validation accepts zero only when requested', () {
expect(validateEpisodeCountInput('0', allowZero: true), isNull);
expect(validateEpisodeCountInput('12', allowZero: false), isNull);
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/serial_future_queue.dart';
void main() {
test('a failed operation does not poison the next queued operation', () async {
final queue = SerialFutureQueue();
await expectLater(queue.run<void>(() async => throw StateError('failed')), throwsStateError);
expect(await queue.run(() async => 42), 42);
await queue.settled;
});
test('operations remain serialized while callers receive their own results', () async {
final queue = SerialFutureQueue();
final firstStarted = Completer<void>();
final releaseFirst = Completer<void>();
var secondStarted = false;
final first = queue.run(() async {
firstStarted.complete();
await releaseFirst.future;
return 'first';
});
final second = queue.run(() async {
secondStarted = true;
return 'second';
});
await firstStarted.future;
await Future<void>.delayed(Duration.zero);
expect(secondStarted, isFalse);
releaseFirst.complete();
expect(await first, 'first');
expect(await second, 'second');
});
}
@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/providers/download_provider.dart';
import 'package:plezy/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/utils/smart_deletion_handler.dart';
import 'package:provider/provider.dart';
void main() {
testWidgets('completion before the first dialog frame removes the exact pending route', (tester) async {
final database = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(database);
final manager = DownloadManagerService(
database: database,
storageService: DownloadStorageService.instance,
clientResolver: (_, {clientScopeId}) => null,
)..recoveryFuture = Future<void>.value();
final provider = _GatedDeletionProvider(manager, database);
await provider.ensureInitialized();
addTearDown(() async {
provider.dispose();
manager.dispose();
await database.close();
});
late BuildContext actionContext;
await tester.pumpWidget(
ChangeNotifierProvider<DownloadProvider>.value(
value: provider,
child: MaterialApp(
home: Builder(
builder: (context) {
actionContext = context;
return const Scaffold(body: Text('underlying page'));
},
),
),
),
);
final deletion = SmartDeletionHandler.deleteWithProgress(
context: actionContext,
provider: provider,
globalKey: 'srv:item',
delayMs: 0,
);
await tester.pump();
provider.completeDeletion();
await deletion;
await tester.pump();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('underlying page'), findsOneWidget);
});
}
class _GatedDeletionProvider extends DownloadProvider {
_GatedDeletionProvider(DownloadManagerService manager, AppDatabase database)
: super.forTesting(downloadManager: manager, database: database);
final Completer<void> _deletion = Completer<void>();
@override
Future<void> deleteDownload(String globalKey) => _deletion.future;
void completeDeletion() => _deletion.complete();
}
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/services/base_peer_service.dart';
import 'package:plezy/widgets/companion_remote/discovery_view.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
test('typed peer errors are classified without parsing localized text', () {
expect(
companionRemotePairingErrorMessage(const PeerError(type: PeerErrorType.timeout, message: 'Délai dépassé')),
t.companionRemote.pairing.connectionTimedOut,
);
expect(
companionRemotePairingErrorMessage(const PeerError(type: PeerErrorType.invalidSession, message: 'Sitzung fehlt')),
t.companionRemote.pairing.sessionNotFound,
);
expect(
companionRemotePairingErrorMessage(
const PeerError(type: PeerErrorType.authFailed, message: 'Échec de lauthentification'),
),
t.companionRemote.pairing.authFailed,
);
});
test('typed fallback errors preserve their localized producer message', () {
expect(
companionRemotePairingErrorMessage(
const PeerError(type: PeerErrorType.networkError, message: 'Localized network failure'),
),
'Localized network failure',
);
});
}
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -64,4 +67,49 @@ void main() {
expect(placeholder, findsOneWidget);
expect(tester.getSize(placeholder), const Size(96, 96));
});
testWidgets('same local artwork path re-resolves after the file appears', (tester) async {
final directory = Directory.systemTemp.createTempSync('plezy-image-test');
addTearDown(() => directory.deleteSync(recursive: true));
final file = File('${directory.path}/poster.png');
late StateSetter rebuild;
await tester.pumpWidget(
MaterialApp(
home: StatefulBuilder(
builder: (context, setState) {
rebuild = setState;
return OptimizedMediaImage.thumb(
imagePath: null,
localFilePath: file.path,
width: 80,
height: 120,
fallbackIcon: Symbols.image_not_supported_rounded,
);
},
),
),
);
expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
expect(find.byIcon(Symbols.image_not_supported_rounded), findsOneWidget);
file.writeAsBytesSync(
base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),
flush: true,
);
rebuild(() {});
await tester.pump();
expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing);
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await tester.pump();
await tester.pump();
expect(file.existsSync(), isTrue);
expect(find.byIcon(Symbols.image_not_supported_rounded), findsNothing);
expect(find.byType(Image), findsOneWidget);
await tester.pumpWidget(const SizedBox.shrink());
});
}
@@ -3,6 +3,7 @@
// be found in the LICENSE file.
import Flutter
import UIKit
public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private let connectivityProvider: ConnectivityProvider
@@ -79,8 +80,12 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl
}
private func connectivityUpdateHandler(connectivityTypes: [ConnectivityType]) {
DispatchQueue.main.async {
self.eventSink?(self.statusFrom(connectivityTypes: connectivityTypes))
DispatchQueue.main.async { [weak self] in
guard let self = self, let eventSink = self.eventSink else { return }
// NWPathMonitor can emit after the FlutterEngine shell is torn down.
// Do not call its event sink while tvOS is backgrounded.
guard UIApplication.shared.applicationState != .background else { return }
eventSink(self.statusFrom(connectivityTypes: connectivityTypes))
}
}