diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bfb45bc..01461e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/analysis_options.yaml b/analysis_options.yaml index 35a78da4..8a175505 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,6 +18,8 @@ linter: avoid_print: true dart_code_linter: + rules-exclude: + - "test/**" metrics-exclude: - "test/**" diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt index 3e4bc03a..0929fc97 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt @@ -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) } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt index 3296a361..439ae0a4 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/shared/MediaCodecQueryTest.kt @@ -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")) + } } diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index be6f52de..f1290242 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -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} негледани", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 807e3ab2..cf681e74 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 35346d49..8f8e5cae 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6002d66a..e74b8fbd 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 10a2cf97..d02f375f 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index f13ad38a..9462563a 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index a168b005..3fef8d8c 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 4158914c..541a667a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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}件保持", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index e87ca171..2e37f6b6 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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}개 유지", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index a02e565d..223f5db7 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index ffa98845..607558bd 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0dd2d480..77310427 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 46b297ec..e3f325aa 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 8d2b19be..66104840 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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} непросмотренных", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 5dbfb92d..23300cee 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index ed685946..f7e7b7b0 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -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} негледани', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index 2fb8b2cd..f16c7fda 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -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', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index ca8e0078..a1dffa70 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -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', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index cd2e70de..496c1613 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index bd784494..d243c1bb 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -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', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index e604545e..4b0340cc 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -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', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 7a3504b2..872f0900 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -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', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 5177ee1f..0100204a 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -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}件保持', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index aa94103c..4b56b038 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -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}개 유지', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 663e2545..871cc55f 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -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', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index d10f8abd..a5f60523 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -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', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 3412e1ea..f5feb840 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -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', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index a2f1fb60..e7fa7395 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -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', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 845cf1a2..92d7fc48 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -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} непросмотренных', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 8808a5bf..35653441 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -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', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index d92c34c4..4b00d9aa 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -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}个未观看', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 1daf0bed..616f7f31 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index b3d4824a..212c44b0 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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}个未观看", diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index fcde802d..74ae4a00 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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 _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') { diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 9a856ed5..5ba018d0 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -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 diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 6bc6b731..c48950bd 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -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 Function(List)? syncSystemShelf, + }) : _syncSystemShelfOverride = syncSystemShelf { _loadCoordinator = CoalescedLoadCoordinator(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 Function(List)? _syncSystemShelfOverride; StreamSubscription? _watchStateSubscription; StreamSubscription? _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 diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart index ae9c1478..6e6d2643 100644 --- a/lib/providers/download_metadata_store.dart +++ b/lib/providers/download_metadata_store.dart @@ -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; diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 7bb5dcd7..49c7b73e 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -292,6 +292,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } + @visibleForTesting + Future debugHydrateOfflineWatchOverlay() => _applyOfflineWatchOverlay(); + /// Load all persisted downloads and metadata from the database/cache Future _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--; diff --git a/lib/screens/livetv/live_tv_refresh_lifecycle.dart b/lib/screens/livetv/live_tv_refresh_lifecycle.dart new file mode 100644 index 00000000..eaaffff6 --- /dev/null +++ b/lib/screens/livetv/live_tv_refresh_lifecycle.dart @@ -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, + }; +} diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 08def534..449c2f71 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -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 final Map _favoriteModeByStore = {}; Future? _channelsLoadFuture; int _favoritesLoadGeneration = 0; - int _favoritesMutationGeneration = 0; + Future? _favoritesLoadFuture; + final SerialFutureQueue _favoritesMutationQueue = SerialFutureQueue(); + bool _favoritesLoaded = false; List get _filteredChannels => filterLiveTvChannelsForFavorites( channels: _channels, @@ -402,7 +405,15 @@ class _LiveTvScreenState extends State _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 Future _loadFavorites(MultiServerProvider multiServer) async { final loadGeneration = ++_favoritesLoadGeneration; - final mutationGeneration = _favoritesMutationGeneration; + _favoritesLoaded = false; try { final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); @@ -450,11 +461,7 @@ class _LiveTvScreenState extends State } } - if (!mounted || - loadGeneration != _favoritesLoadGeneration || - mutationGeneration != _favoritesMutationGeneration) { - return; - } + if (!mounted || loadGeneration != _favoritesLoadGeneration) return; setState(() { _favoriteSourceByLiveServer ..clear() @@ -471,6 +478,7 @@ class _LiveTvScreenState extends State _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 } 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 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 _persistFavorites() async { final multiServer = context.read(); final byStore = >{}; - 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 = {}; + final writes = >[]; 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 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 [], FavoriteChannelPersistenceMode.serverSlice => - (byStore[storeKey] ?? const []).where((f) => f.source == source).toList(), + (byStore[storeKey] ?? const []).where((favorite) => favorite.source == source).toList(), }; - unawaited(client.liveTv.setFavoriteChannels(channels)); + writes.add(client.liveTv.setFavoriteChannels(channels)); } + await Future.wait(writes); } void _focusCurrentTab() { diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 52287cb3..349abdb5 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -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 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; } } diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 618a9a96..2f3ec830 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -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 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 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 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()); } diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 3ac4460e..7bbae440 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -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 channels; @@ -41,7 +42,7 @@ class WhatsOnTabState extends State List> _hubKeys = []; bool _refreshRequested = true; bool _tickerEnabled = false; - bool _appResumed = true; + bool _appRefreshActive = true; @override List get liveTvChannels => widget.channels; @@ -64,10 +65,18 @@ class WhatsOnTabState extends State @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 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()); } diff --git a/lib/screens/video_player/live_stream_retry.dart b/lib/screens/video_player/live_stream_retry.dart index dd05252c..b1bdd2af 100644 --- a/lib/screens/video_player/live_stream_retry.dart +++ b/lib/screens/video_player/live_stream_retry.dart @@ -16,10 +16,13 @@ Future runLiveStreamRetry({ 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 runLiveStreamRetry({ 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(); } } diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index e2a5d63c..e122eb55 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -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; } } diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 7a9ec740..84222627 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -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; diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index 3c5bf64d..986fa3a3 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -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? _clientSocketSubscription; + StreamSubscription? _channelSubscription; String? _myPeerId; String? _hostAddress; // Format: "ip:port" @@ -223,7 +226,8 @@ class CompanionRemotePeerService with KeepaliveMixin { } }); - socket.listen( + late final StreamSubscription 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? 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? _encryptChain; - Future? _decryptChain; - Future? _sendChain; + final SerialFutureQueue _encryptQueue = SerialFutureQueue(); + final SerialFutureQueue _decryptQueue = SerialFutureQueue(); + final SerialFutureQueue _sendQueue = SerialFutureQueue(); Future> _encryptOutgoing(String plaintext) { - final result = (_encryptChain ?? Future.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((_) {}); - return result; } Future _sendEncryptedToSocket(WebSocket socket, String plaintext) async { @@ -797,9 +811,7 @@ class CompanionRemotePeerService with KeepaliveMixin { Future _decryptIncoming(dynamic data) { if (_sessionEncKey == null) return Future.value(); - final result = (_decryptChain ?? Future.value()).then((_) => _decryptIncomingNow(data)); - _decryptChain = result.then((_) {}); - return result; + return _decryptQueue.run(() => _decryptIncomingNow(data)); } Future _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 _runDisconnectCleanup(Future? operation, String name) async { + if (operation == null) return; + try { + await operation; + } catch (e) { + appLogger.d('CompanionRemote: $name cleanup ignored', error: e); + } + } + Future 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. diff --git a/lib/services/jellyfin_api_cache.dart b/lib/services/jellyfin_api_cache.dart index e0e682ae..3fdf4ca9 100644 --- a/lib/services/jellyfin_api_cache.dart +++ b/lib/services/jellyfin_api_cache.dart @@ -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 = { + 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; diff --git a/lib/services/jellyfin_cache_resolver.dart b/lib/services/jellyfin_cache_resolver.dart index 44ea7712..324fcaaa 100644 --- a/lib/services/jellyfin_cache_resolver.dart +++ b/lib/services/jellyfin_cache_resolver.dart @@ -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 = >{}; + 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 = []; 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 _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 findConnection(String serverOrScopeId, {String? userId}) async { final scope = _splitScope(serverOrScopeId); if (scope.userId != null && userId != null && scope.userId != userId) return null; diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index deaf5787..075e6262 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -90,7 +90,8 @@ class MultiServerManager { /// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps Future? _activeReconnect; - int _profileRefreshGeneration = 0; + int _profileRefreshEpoch = 0; + final Map _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 = {}; 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 _detachAllClients() { - ++_profileRefreshGeneration; + ++_profileRefreshEpoch; + _profileRefreshGenerations.clear(); _stopNetworkMonitoring(); for (final timer in _reconnectDebounce.values) { timer.cancel(); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index b68354cf..0e0c3fd3 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -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 _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); diff --git a/lib/utils/serial_future_queue.dart b/lib/utils/serial_future_queue.dart new file mode 100644 index 00000000..6a2426a4 --- /dev/null +++ b/lib/utils/serial_future_queue.dart @@ -0,0 +1,26 @@ +/// Runs asynchronous operations one at a time without allowing a failed +/// operation to poison the queue tail. +class SerialFutureQueue { + Future _tail = Future.value(); + + Future run(Future Function() operation) { + final result = _tail.then((_) => operation()); + _tail = _settle(result); + return result; + } + + Future get settled => _tail; + + void reset() { + _tail = Future.value(); + } + + static Future _settle(Future 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. + } + } +} diff --git a/lib/utils/smart_deletion_handler.dart b/lib/utils/smart_deletion_handler.dart index de90c0a9..0fd1b2cc 100644 --- a/lib/utils/smart_deletion_handler.dart +++ b/lib/utils/smart_deletion_handler.dart @@ -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 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( + static ({NavigatorState navigator, DialogRoute route}) _showProgressDialog( + BuildContext context, + String globalKey, + ) { + final navigator = Navigator.of(context); + final route = DialogRoute( context: context, barrierDismissible: false, - builder: (dialogContext) => KeyedSubtree( - key: dialogKey, - child: Consumer( - builder: (context, provider, child) { - final progress = provider.getDeletionProgress(globalKey); + builder: (_) => Consumer( + 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); } } diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 2a7169c9..06c2b694 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -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 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 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': diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 24de561f..1a84d18f 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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); } diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 54dd5313..238bd42d 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -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; }); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 54c889be..f6677df1 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -541,6 +541,7 @@ class _PlexVideoControlsState extends State 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 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 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 // 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) { diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index ea5a14ef..46525b8d 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -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)" diff --git a/test/mpv/player_gapless_test.dart b/test/mpv/player_gapless_test.dart index 45c19be4..1d6bf2e8 100644 --- a/test/mpv/player_gapless_test.dart +++ b/test/mpv/player_gapless_test.dart @@ -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.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 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(); + 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'); } diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index e7cd3b4e..87adf207 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -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(); diff --git a/test/providers/discover_provider_test.dart b/test/providers/discover_provider_test.dart index 0755649a..71ea6a96 100644 --- a/test/providers/discover_provider_test.dart +++ b/test/providers/discover_provider_test.dart @@ -151,6 +151,7 @@ void main() { late HiddenLibrariesProvider hiddenLibraries; late LibrariesProvider libraries; late DiscoverProvider provider; + late List> 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.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 { diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 2c1cae7f..2b9e5566 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -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 tracks; + final Future? gate; + final Completer? started; final fetchPlayableDescendantsCalls = []; @override Future> 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(); + final release = Completer(); + 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 = []; + 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( diff --git a/test/screens/livetv/live_tv_refresh_lifecycle_test.dart b/test/screens/livetv/live_tv_refresh_lifecycle_test.dart new file mode 100644 index 00000000..1510c1f9 --- /dev/null +++ b/test/screens/livetv/live_tv_refresh_lifecycle_test.dart @@ -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); + }); +} diff --git a/test/screens/video_player/live_stream_retry_test.dart b/test/screens/video_player/live_stream_retry_test.dart index 447616a0..77058a92 100644 --- a/test/screens/video_player/live_stream_retry_test.dart +++ b/test/screens/video_player/live_stream_retry_test.dart @@ -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 run() => runLiveStreamRetry( 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, ); diff --git a/test/services/jellyfin_api_cache_test.dart b/test/services/jellyfin_api_cache_test.dart index be9f0d1d..a55485f1 100644 --- a/test/services/jellyfin_api_cache_test.dart +++ b/test/services/jellyfin_api_cache_test.dart @@ -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); + } + }); }); } diff --git a/test/services/jellyfin_cache_resolver_test.dart b/test/services/jellyfin_cache_resolver_test.dart index e7afbcd5..f6d75e79 100644 --- a/test/services/jellyfin_cache_resolver_test.dart +++ b/test/services/jellyfin_cache_resolver_test.dart @@ -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>> runSelect(QueryExecutor executor, String statement, List args) { + count++; + return executor.runSelect(statement, args); + } } diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 6f317ac0..a2987f0d 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -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', () { diff --git a/test/utils/download_utils_test.dart b/test/utils/download_utils_test.dart new file mode 100644 index 00000000..c74daa0d --- /dev/null +++ b/test/utils/download_utils_test.dart @@ -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); + }); +} diff --git a/test/utils/serial_future_queue_test.dart b/test/utils/serial_future_queue_test.dart new file mode 100644 index 00000000..efdaf991 --- /dev/null +++ b/test/utils/serial_future_queue_test.dart @@ -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(() 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(); + final releaseFirst = Completer(); + 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.delayed(Duration.zero); + expect(secondStarted, isFalse); + + releaseFirst.complete(); + expect(await first, 'first'); + expect(await second, 'second'); + }); +} diff --git a/test/utils/smart_deletion_handler_test.dart b/test/utils/smart_deletion_handler_test.dart new file mode 100644 index 00000000..3c43329e --- /dev/null +++ b/test/utils/smart_deletion_handler_test.dart @@ -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.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.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 _deletion = Completer(); + + @override + Future deleteDownload(String globalKey) => _deletion.future; + + void completeDeletion() => _deletion.complete(); +} diff --git a/test/widgets/companion_remote_discovery_view_test.dart b/test/widgets/companion_remote_discovery_view_test.dart new file mode 100644 index 00000000..f9f5bca3 --- /dev/null +++ b/test/widgets/companion_remote_discovery_view_test.dart @@ -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 l’authentification'), + ), + 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', + ); + }); +} diff --git a/test/widgets/optimized_media_image_test.dart b/test/widgets/optimized_media_image_test.dart index 421f860c..2cb583b9 100644 --- a/test/widgets/optimized_media_image_test.dart +++ b/test/widgets/optimized_media_image_test.dart @@ -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.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.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()); + }); } diff --git a/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift b/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift index b5d12115..464bdd77 100644 --- a/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift +++ b/tvos/Runner/Plugins/connectivity_plus/ConnectivityPlusPlugin.swift @@ -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)) } }