diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt new file mode 100644 index 00000000..0305978e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt @@ -0,0 +1,91 @@ +package com.nuvio.app.features.player + +import android.content.res.Resources +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTrackNameProvider + +@UnstableApi +class CustomDefaultTrackNameProvider(resources: Resources) : DefaultTrackNameProvider(resources) { + + override fun getTrackName(format: Format): String { + var trackName = super.getTrackName(format) + + if (format.sampleMimeType != null) { + var sampleFormat = formatNameFromMime(format.sampleMimeType) + if (sampleFormat == null) { + sampleFormat = formatNameFromMime(format.codecs) + } + if (sampleFormat == null) { + sampleFormat = format.sampleMimeType + } + if (sampleFormat != null) { + trackName += " ($sampleFormat)" + } + } + + if (format.label != null) { + if (!trackName.startsWith(format.label!!)) { + trackName += " - ${format.label}" + } + } + + return trackName + } + + companion object { + fun formatNameFromMime(mimeType: String?): String? { + if (mimeType == null) return null + + return when (mimeType) { + MimeTypes.AUDIO_DTS -> "DTS" + MimeTypes.AUDIO_DTS_HD -> "DTS-HD" + MimeTypes.AUDIO_DTS_EXPRESS -> "DTS Express" + MimeTypes.AUDIO_TRUEHD -> "TrueHD" + MimeTypes.AUDIO_AC3 -> "AC-3" + MimeTypes.AUDIO_E_AC3 -> "E-AC-3" + MimeTypes.AUDIO_E_AC3_JOC -> "E-AC-3-JOC" + MimeTypes.AUDIO_AC4 -> "AC-4" + MimeTypes.AUDIO_AAC -> "AAC" + MimeTypes.AUDIO_MPEG -> "MP3" + MimeTypes.AUDIO_MPEG_L2 -> "MP2" + MimeTypes.AUDIO_VORBIS -> "Vorbis" + MimeTypes.AUDIO_OPUS -> "Opus" + MimeTypes.AUDIO_FLAC -> "FLAC" + MimeTypes.AUDIO_ALAC -> "ALAC" + MimeTypes.AUDIO_WAV -> "WAV" + MimeTypes.AUDIO_AMR -> "AMR" + MimeTypes.AUDIO_AMR_NB -> "AMR-NB" + MimeTypes.AUDIO_AMR_WB -> "AMR-WB" + MimeTypes.AUDIO_IAMF -> "IAMF" + MimeTypes.AUDIO_MPEGH_MHA1 -> "MPEG-H" + MimeTypes.AUDIO_MPEGH_MHM1 -> "MPEG-H" + MimeTypes.VIDEO_H264 -> "AVC" + MimeTypes.VIDEO_H265 -> "HEVC" + MimeTypes.VIDEO_AV1 -> "AV1" + MimeTypes.VIDEO_VP8 -> "VP8" + MimeTypes.VIDEO_VP9 -> "VP9" + MimeTypes.VIDEO_DOLBY_VISION -> "Dolby Vision" + "application/pgs" -> "PGS" + MimeTypes.APPLICATION_SUBRIP -> "SRT" + MimeTypes.TEXT_SSA -> "SSA" + MimeTypes.TEXT_VTT -> "VTT" + MimeTypes.APPLICATION_TTML -> "TTML" + MimeTypes.APPLICATION_TX3G -> "TX3G" + MimeTypes.APPLICATION_DVBSUBS -> "DVB" + else -> null + } + } + + fun getChannelLayoutName(channelCount: Int): String? { + return when (channelCount) { + 1 -> "Mono" + 2 -> "Stereo" + 6 -> "5.1" + 8 -> "7.1" + else -> if (channelCount > 0) "${channelCount}ch" else null + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index b6f7be0c..ebdcfd92 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -58,7 +58,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.HttpURLConnection import java.net.URL -import java.util.Locale private const val TAG = "NuvioPlayer" @@ -180,6 +179,10 @@ actual fun PlatformPlayerSurface( var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } + fun syncPlayerViewKeepScreenOn() { + playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn() + } + DisposableEffect(exoPlayer) { PlayerPictureInPictureManager.registerPausePlaybackCallback { exoPlayer.pause() @@ -187,6 +190,7 @@ actual fun PlatformPlayerSurface( val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { + syncPlayerViewKeepScreenOn() latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) }) } @@ -203,10 +207,12 @@ actual fun PlatformPlayerSurface( latestOnError.value(null) exoPlayer.logCurrentTracks("STATE_READY") } + syncPlayerViewKeepScreenOn() latestOnSnapshot.value(exoPlayer.snapshot()) } override fun onIsPlayingChanged(isPlaying: Boolean) { + syncPlayerViewKeepScreenOn() latestOnSnapshot.value(exoPlayer.snapshot()) } @@ -236,6 +242,7 @@ actual fun PlatformPlayerSurface( onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) exoPlayer.removeListener(listener) + playerViewRef?.keepScreenOn = false subtitleSelectionJob?.cancel() } } @@ -265,6 +272,7 @@ actual fun PlatformPlayerSurface( LaunchedEffect(exoPlayer, playWhenReady) { exoPlayer.playWhenReady = playWhenReady + syncPlayerViewKeepScreenOn() latestOnSnapshot.value(exoPlayer.snapshot()) } @@ -298,10 +306,10 @@ actual fun PlatformPlayerSurface( } override fun getAudioTracks(): List = - exoPlayer.extractAudioTracks() + exoPlayer.extractAudioTracks(context) override fun getSubtitleTracks(): List { - val tracks = exoPlayer.extractSubtitleTracks() + val tracks = exoPlayer.extractSubtitleTracks(context) Log.d(TAG, "getSubtitleTracks: found ${tracks.size} tracks") tracks.forEach { t -> Log.d(TAG, " track idx=${t.index} id=${t.id} label='${t.label}' lang=${t.language} selected=${t.isSelected}") @@ -425,7 +433,7 @@ actual fun PlatformPlayerSurface( useController = useNativeController layoutParams = android.view.ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) player = exoPlayer - keepScreenOn = true + keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn() this.resizeMode = resizeMode.toExoResizeMode() setShutterBackgroundColor(android.graphics.Color.BLACK) playerViewRef = this @@ -442,6 +450,7 @@ actual fun PlatformPlayerSurface( playerView.useController = useNativeController playerView.resizeMode = resizeMode.toExoResizeMode() playerViewRef = playerView + syncPlayerViewKeepScreenOn() playerView.syncLibassOverlay( player = exoPlayer, enabled = useLibass, @@ -470,6 +479,11 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = playbackSpeed = playbackParameters.speed, ) +private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean = + playerError == null && + playWhenReady && + playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY) + private fun PlayerResizeMode.toExoResizeMode(): Int = when (this) { PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT @@ -559,47 +573,20 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { } } -private fun ExoPlayer.extractAudioTracks(): List { +private fun ExoPlayer.extractAudioTracks(context: Context): List { val tracks = mutableListOf() + val trackNameProvider = CustomDefaultTrackNameProvider(context.resources) var idx = 0 for (group in currentTracks.groups) { if (group.type != C.TRACK_TYPE_AUDIO) continue val format = group.mediaTrackGroup.getFormat(0) - val channelLabel = when { - format.channelCount == 1 -> "Mono" - format.channelCount == 2 -> "Stereo" - format.channelCount == 6 -> "5.1" - format.channelCount == 8 -> "7.1" - format.channelCount > 0 -> "${format.channelCount}ch" - else -> null - } - val mime = format.sampleMimeType?.lowercase() - val codecLabel = when { - mime == null -> null - mime.contains("eac3-joc") -> "Dolby Atmos" - mime.contains("truehd") && format.channelCount >= 8 -> "Dolby Atmos" - mime.contains("truehd") -> "Dolby TrueHD" - mime.contains("eac3") -> "Dolby Digital Plus" - mime.contains("ac3") -> "Dolby Digital" - mime.contains("opus") -> "Opus" - mime.contains("aac") -> "AAC" - mime.contains("dts-hd") -> "DTS-HD" - mime.contains("dts") -> "DTS" - else -> null - } - val resolvedLanguage = format.language?.let { lang -> Locale(lang).displayLanguage.takeIf { name -> name.isNotBlank() && name != lang } } - val baseName = format.label?.takeIf { it.isNotBlank() } - ?: resolvedLanguage - ?: format.language + val label = trackNameProvider.getTrackName(format).takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.compose_player_track_number, idx + 1) } - val suffix = listOfNotNull(channelLabel, codecLabel) - .joinToString(" ") - .let { if (it.isNotBlank()) " ($it)" else "" } tracks.add( AudioTrack( index = idx, id = format.id ?: idx.toString(), - label = "$baseName$suffix", + label = label, language = format.language, isSelected = group.isSelected, ) @@ -609,8 +596,9 @@ private fun ExoPlayer.extractAudioTracks(): List { return tracks } -private fun ExoPlayer.extractSubtitleTracks(): List { +private fun ExoPlayer.extractSubtitleTracks(context: Context): List { val tracks = mutableListOf() + val trackNameProvider = CustomDefaultTrackNameProvider(context.resources) var idx = 0 for (group in currentTracks.groups) { if (group.type != C.TRACK_TYPE_TEXT) continue @@ -620,7 +608,7 @@ private fun ExoPlayer.extractSubtitleTracks(): List { SubtitleTrack( index = idx, id = format.id ?: idx.toString(), - label = format.label ?: "", + label = trackNameProvider.getTrackName(format), language = format.language, isSelected = group.isSelected, isForced = inferForcedSubtitleTrack( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index bc1a8734..c02f9f6a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -35,7 +35,7 @@ actual fun LockPlayerToLandscape() { } @Composable -actual fun EnterImmersivePlayerMode() { +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { val activity = LocalContext.current.findActivity() ?: return DisposableEffect(activity) { diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml index 5599b948..7d3f2e85 100644 --- a/composeApp/src/androidMain/res/xml/locale_config.xml +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -8,4 +8,5 @@ + diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 00000000..01141177 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,1199 @@ + + Anerkennung und Projekt-Credits öffnen + Zurück + Abbrechen + Schließen + Löschen + Fertig + Bearbeiten + Importieren + Weiter + OK + Abspielen + Zurück + Entfernen + Neu anordnen + Zurücksetzen + Fortsetzen + Erneut versuchen + Speichern + Wird installiert + Addons + Aktiv + %1$d Kataloge + Konfigurierbar + Wird aktualisiert + %1$d Ressourcen + Nicht verfügbar + Addon konfigurieren + Addon löschen + Füge eine Manifest-URL hinzu, um Kataloge, Metadaten, Streams oder Untertitel in Nuvio zu laden. + Noch keine Addons installiert. + Gib eine Addon-URL ein. + Addon-URL + Addon installieren + Manifest-Details werden geladen... + Manifest-URL wird geprüft und Addon-Details werden vor der Installation geladen. + Addon wird geprüft + Installation fehlgeschlagen + %1$s wurde erfolgreich geprüft und hinzugefügt. + Addon installiert + Addon nach unten verschieben + Addon nach oben verschieben + Aktiv + Addons + Kataloge + Addon aktualisieren + Addon hinzufügen + Installierte Addons + Übersicht + %1$d ID-Regeln + Version %1$s + Ausgewählt + JSON kopieren + %1$d Sammlung(en), %2$d Ordner + "%1$s" löschen? Dies kann nicht rückgängig gemacht werden. + Sammlung löschen + Katalog hinzufügen + Ordner hinzufügen + Alle Genres + Füge Kataloge aus deinen installierten Addons hinzu, um festzulegen, was dieser Ordner anzeigt. + Noch keine Katalogquellen + Auswählen + Emoji + Bild-URL + Keine + Cover + Sammlung erstellen + Fertig + Sammlung bearbeiten + Ordner bearbeiten + Lege Identität, Darstellung und Katalogquellen des Ordners mit derselben Struktur wie im Hauptsammlungs-Editor fest. + Füge einen hinzu, um zu beginnen. + Noch keine Ordner + Ordner + Genre-Filter + Nur das Cover-Bild anzeigen + Titel ausblenden + Neuer Ordner + Diese Sammlung über allen regulären Home-Katalogen anzeigen. Mehrere angeheftete Sammlungen folgen der Erstellungsreihenfolge. + Über Katalogen anheften + URL des Hintergrundbilds (optional) + Ordnername + URL des animierten GIFs (wird nur im Fokus abgespielt) + Name der Sammlung + Änderungen speichern + Speichern + Erscheinungsbild + Grundlagen + Katalogquellen + Wähle die Addon-Kataloge aus, die dieser Ordner zusammenfassen soll. + Kataloge auswählen + Genre auswählen + %1$d ausgewählt + %1$d Kataloge + %1$d ausgewählt + Poster + Quadratisch + Breit + Alle Kataloge in einem Tab kombinieren + \"Alle\"-Tab anzeigen + Wenn verfügbar, wird das konfigurierte GIF anstelle des statischen Covers abgespielt. + GIF anzeigen, wenn konfiguriert + %1$d Quelle(n) · %2$s + Kachelform + Reihen + Tabs + Ansichtsmodus + TMDB-Quellen + Öffentliche Liste + Produktion + Sender + Sammlung + Person + Regisseur + Benutzerdefiniert + Wähle eine vorgefertigte Quelle. Du kannst sie nach dem Hinzufügen bearbeiten oder entfernen. + Füge eine öffentliche TMDB-Listen-URL ein oder nur die Nummer aus der URL. + Suche nach einem Studionamen oder füge eine TMDB-Firmen-ID/-URL ein und füge sie direkt hinzu. + Gib eine Sender-ID ein. Gängige Sender sind in den Voreinstellungen und Schnellfiltern verfügbar. + Suche nach einem Filmsammlungsnamen oder füge die Sammlungs-ID von TMDB ein. + Gib eine TMDB-Personen-ID oder -URL ein, um eine Reihe aus Schauspieler-Credits zu erstellen. + Gib eine TMDB-Personen-ID oder -URL ein, um eine Reihe aus Regie-Credits zu erstellen. + Erstelle eine Live-TMDB-Reihe mit optionalen Filtern. Lass Felder leer, wenn du diesen Filter nicht benötigst. + Öffentliche TMDB-Liste + Sender-ID + Sammlungs-ID + Personen-ID + Produktionsfirmenname, ID oder URL + TMDB-ID oder -URL + https://www.themoviedb.org/list/8504994 oder 8504994 + 213 für Netflix, 49 für HBO, 2739 für Disney+ + 10 für Star-Wars-Sammlung + Marvel Studios, 420 oder Firmen-URL + 31 für Tom Hanks oder Personen-URL + Beispiele: Marvel Studios, 420 oder https://www.themoviedb.org/company/420. + Beispiel: Star Wars Collection, Harry Potter Collection oder eine Sammlungs-URL. + Beispiel-IDs: Netflix 213, HBO 49, Disney+ 2739. + Beispiel: https://www.themoviedb.org/list/8504994 oder 8504994. + Beispiel: https://www.themoviedb.org/person/31-tom-hanks oder 31. + Anzeigetitel + Wird als Reihen-/Tab-Name angezeigt. Wenn leer, erstellt Nuvio einen aus der Quelle. + Marvel-Filme, Netflix Originals, Pixar + Tom-Hanks-Filme, Lieblingsschauspieler + Christopher-Nolan-Filme, Lieblingsregisseure + Beste Actionfilme, Koreanische Dramen, Animation 2024 + Suchergebnisse + TMDB-Sammlung + TMDB-Firma %1$d + TMDB-Sammlung %1$d + Typ + Filme + Serien + Beides + Sortieren + Filter + Lass Felder leer, wenn du diesen Filter nicht benötigst. + Schnell-Genres + Schnell-Sprachen + Schnell-Länder + Schnell-Stichwörter + Schnell-Studios + Schnell-Sender + Genre-IDs + Verwende TMDB-Genrenummern. Trenne mehrere mit Kommas für UND oder mit senkrechten Strichen für ODER. + Erscheinungs- oder Ausstrahlungsdatum von + Erscheinungs- oder Ausstrahlungsdatum bis + Verwende JJJJ-MM-TT, zum Beispiel 2024-01-01. + Mindestbewertung + Maximalbewertung + TMDB-Bewertung von 0 bis 10. Beispiel: 7.0. + Mindeststimmen + Verwende dies, um obskure Titel mit wenigen Stimmen zu vermeiden. Beispiel: 100. + Originalsprache + Verwende zweistellige Sprachcodes, zum Beispiel en, ko, ja, hi. + Herkunftsland + Verwende zweistellige Ländercodes, zum Beispiel US, KR, JP, IN. + Stichwort-IDs + Verwende TMDB-Stichwortnummern. Schnell-Chips füllen gängige Beispiele aus. + 9715 für Superheld + Firmen-IDs + Verwende Studio-/Firmen-IDs. Schnell-Chips füllen gängige Beispiele aus. + 420 für Marvel Studios + Sender-IDs + Nur für Serien. Verwende Sender-IDs wie Netflix 213 oder HBO 49. + 213 für Netflix + Jahr + Verwende eine vierstellige Jahreszahl, zum Beispiel 2024. + Voreinstellungen + Suchen + Quelle hinzufügen + Trakt-Liste hinzufügen + Trakt-Liste bearbeiten + Trakt-Listen + Trakt-Liste + Titel, Trakt-URL oder Listen-ID suchen + Verwende eine öffentliche Trakt-Listen-URL oder eine numerische Listen-ID, oder suche nach Namen. + Wochenend-Watch, Preisträger + Suchergebnisse + Angesagte Listen + Beliebte Listen + Richtung + Aufsteigend + Absteigend + Listenreihenfolge + Kürzlich hinzugefügt + Titel + Veröffentlicht + Laufzeit + Beliebt + Prozentsatz + Stimmen + Action + Abenteuer + Animation + Komödie + Horror + Sci-Fi + Drama + Krimi + Reality + Englisch + Koreanisch + Japanisch + Hindi + Spanisch + Vereinigte Staaten + Korea + Japan + Indien + Vereinigtes Königreich + Superheld + Nach einem Roman + Zeitreise + Weltraum + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Beliebt + Bestbewertet + Aktuell + TMDB-Liste + TMDB-Filmsammlung + Produktion + Sender + Person + Regisseur + TMDB Discover + Erstelle eine, um deine Kataloge zu organisieren. + Noch keine Sammlungen + %1$d Ordner + Keine Einträge gefunden + Ordner nicht gefunden + Sammlungen + Sammlungen importieren + JSON + Füge unten dein Sammlungs-JSON ein. + Importieren + Neue Sammlung + Angeheftet + Alle + Deine Sammlungen + Mit ❤️ erstellt von Tapframe und Freunden + Version %1$s (%2$s) + Aus + Ein + Pause + Neu laden + Hast du bereits ein Konto? + Ohne Konto fortfahren + Konto erstellen + Du hast kein Konto? + E-Mail + oder + Passwort + Melde dich an, um auf deine Bibliothek und Fortschritt zuzugreifen + Anmelden + Registriere dich, um deine Daten geräteübergreifend zu synchronisieren + Registrieren + Deine Daten werden nur lokal gespeichert + Streame alles, überall + Willkommen zurück + Bibliothek + Trakt-Bibliothek + Start + Bibliothek + Profil + Suche + Tonspuren + Audio + Integriert + Unterer Versatz + Player schließen + Farbe + Wird gerade abgespielt + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episoden + Schriftgröße + %1$dsp + Player-Steuerung sperren + Keine Tonspuren verfügbar + Keine Episoden verfügbar + Keine Streams gefunden + Keine + Umriss + Episoden + Quellen + Streams + Wiedergabefehler + Wird abgespielt + Tippen, um Untertitel zu laden + Zurück + Standard wiederherstellen + Ausfüllen + Anpassen + Zoom + 10 Sekunden zurückspulen + -%1$ds + +%1$ds + -%1$ds + +%1$ds + 10 Sekunden vorspulen + Quellen + Stil + UT + Untertitel + Helligkeit %1$s + Lautstärke %1$s + Stumm + Heruntergeladen + Läuft + TBA + Zum Entsperren tippen + Spur %1$d + Player-Steuerung entsperren + Du schaust gerade + Profil hinzufügen + Suche löschen + Entdecken + Installierte Addons konnten keine gültigen Suchergebnisse zurückgeben. + Suche fehlgeschlagen + Installiere und überprüfe vor der Suche mindestens ein Addon. + Keine aktiven Addons + Installierte durchsuchbare Kataloge haben keine Treffer für diese Suchanfrage zurückgegeben. + Keine Ergebnisse gefunden + Deine installierten Addons bieten keine Katalogsuche an. + Keine durchsuchbaren Kataloge + Filme, Serien suchen... + Letzte Suchen + Letzte Suche entfernen + Über + Allgemein + Konto + Addons + Erscheinungsbild + Inhalte & Entdeckung + Weiterschauen + Startbildschirm + Integrationen + MDBList-Bewertungen + Meta-Bildschirm + Benachrichtigungen + Wiedergabe + Plugins + Poster-Anpassung + Einstellungen + Unterstützer & Mitwirkende + TMDB-Anreicherung + Trakt + ÜBER + Verwalte dein Konto, melde dich ab oder lösche es. + KONTO + Passe Startseiten-Darstellung und visuelle Einstellungen an. + Suche nach neuen Versionen der App. + Nach Updates suchen + Verwalte Addons und Entdeckungsquellen. + Verwalte deine heruntergeladenen Filme und Episoden. + Downloads + ALLGEMEIN + TMDB- und MDBList-Dienste verbinden. + Verwalte Episode-Release-Benachrichtigungen und sende eine Test-Benachrichtigung. + Zu einem anderen Profil wechseln. + Profil wechseln + Trakt verbinden, Watchlist-Listen synchronisieren und Titel direkt in Trakt speichern. + Deine Trakt-Listen werden geladen… + Wähle, wo dieser Titel auf Trakt gespeichert werden soll + Spenden + Zu Details + Entfernen + Von Anfang an starten + Abspielen + %1$d/10 + Rezension + Spoiler + Noch keine Trakt-Rezensionen verfügbar. + %1$d Likes + Dieser Kommentar enthält Spoiler. + Dieser Kommentar enthält Spoiler und wurde ausgeblendet. + Kommentare + Trailer + %1$s (%2$d) + Trailer + Keine abgeschlossenen Episoden + Noch keine Downloads + %1$d heruntergeladene Episode(n) + Aktiv + Filme + Serien + Downloads anzeigen + Abgeschlossen • %1$s + Wird heruntergeladen • %1$s + Fehlgeschlagen + Pausiert • %1$s + Gesehen + Staffel %1$d + Specials + Da fortfahren, wo du aufgehört hast + Zur Bibliothek hinzufügen + Als ungesehen markieren + Als gesehen markieren + Aus Bibliothek entfernen + Alle anzeigen + Manuell abspielen + %1$s-Logo + Konto + Konto löschen + Damit werden dein Konto und alle zugehörigen Daten dauerhaft gelöscht. + Diese Aktion kann nicht rückgängig gemacht werden. Alle deine Daten, Profile und Sync-Verläufe werden dauerhaft entfernt. + Konto löschen? + E-Mail + Nicht angemeldet + Abmelden + Du wirst zum Anmeldebildschirm zurückgeleitet. + Abmelden? + Status + Anonym + Angemeldet + AMOLED-Schwarz + Reines Schwarz für OLED-Bildschirme verwenden. + App-Sprache + Sprache wählen + Anzeigen, ausblenden und gestalten der Weiterschauen-Reihe. + Voreinstellungen für gemeinsame Posterkartenbreite und Eckenradius anpassen. + ANZEIGE + START + DESIGN + Sammlung • %1$s + Anzeigename + Installiere ein Addon mit Board-kompatiblen Katalogen, um die Reihen des Startbildschirms zu konfigurieren. + Keine Start-Kataloge + Hero-Quelle + Ausgeblendet + Start fokussiert halten + %1$s • Limit erreicht (max. %2$d) + Keine Hero-Quellen ausgewählt + Nicht im Hero + Anheften aus der Sammlung entfernen, um zu verschieben + Angeheftet + Oben angeheftet + Neu anordnen + KATALOGE + KATALOGE & SAMMLUNGEN + SAMMLUNGEN + HERO + HERO-QUELLEN + %1$d von %2$d ausgewählt + Hero anzeigen + Ein Hero-Karussell oben auf der Startseite anzeigen. Wähle unten bis zu 2 Quellkataloge. + %1$d von %2$d Katalogen sichtbar • %3$d Hero-Quellen ausgewählt + Öffne einen Katalog nur, wenn du ihn umbenennen oder neu anordnen möchtest. + Sichtbar + Player, Untertitel und automatische Wiedergabe + Karten-Radius + POSTERKARTEN-STIL + Karten-Breite + Benutzerdefiniert + Passe Kartenbreite und Eckenradius für gemeinsame Posterkarten in der gesamten App an. + Beschriftungen ausblenden + Querformat für Regalposter + Live-Vorschau + %1$s (%2$s) + Eckenradius: %1$ddp + Höhe: %1$ddp + Breite: %1$ddp + Klassisch + Pille + Abgerundet + Scharf + Dezent + Ausgewogen + Komfort + Kompakt + Dicht + Groß + Standard + Beim Öffnen der App nach dem Verlassen des Players ein Popup anzeigen, um dort fortzusetzen, wo du aufgehört hast. + Fortsetzen-Aufforderung beim Start + KARTENSTIL + BEIM START + VERHALTEN „ALS NÄCHSTES“ + SICHTBARKEIT + Die Weiterschauen-Reihe auf dem Startbildschirm anzeigen. + Weiterschauen anzeigen + Poster + Posterkarte mit Artwork zuerst + Breit + Informationsdichte horizontale Karte + Wenn aktiviert, fährt „Als Nächstes“ immer mit der am weitesten gesehenen Episode fort. Wenn deaktiviert, folgt es der zuletzt gesehenen Episode. Nützlich, wenn du frühere Episoden erneut anschaust. + „Als Nächstes“ ab letzter Episode + START + QUELLEN + Installiere, entferne, aktualisiere und sortiere deine Inhaltsquellen. + Installiere JavaScript-Scraper-Repositories und teste Anbieter intern. + Lege fest, welche Kataloge auf der Startseite und in welcher Reihenfolge erscheinen. + Detail-Abschnitte deaktivieren und alles unterhalb des Hero neu anordnen. + Erstelle benutzerdefinierte Katalog-Gruppierungen mit Ordnern, die auf der Startseite angezeigt werden. + INTEGRATIONEN + Erweitere Detailseiten mit TMDB-Artwork, Credits, Episoden-Metadaten und mehr. + Füge IMDb, Rotten Tomatoes, Metacritic und andere externe Bewertungen zu Detailseiten hinzu. + Füge unten deinen MDBList-API-Schlüssel hinzu, bevor du Bewertungen aktivierst. + Hole dir einen Schlüssel unter https://mdblist.com/preferences und füge ihn hier ein. + API-Schlüssel + MDBList-API-Schlüssel + MDBList-Bewertungen aktivieren + Externe Bewertungen von MDBList auf Metadatenseiten anzeigen, wenn eine IMDb-ID verfügbar ist. + API-SCHLÜSSEL + BEWERTUNGSANBIETER + MDBLIST + Aktionen + Wiedergabe- und Speichersteuerung. + Besetzung + Hauptbesetzungsliste. + Kinematischer Hintergrund + Verschwommener Backdrop hinter dem Inhalt, ähnlich dem Stream-Bildschirm. + Sammlung + Reihe für verwandte Sammlung oder Franchise. + Kommentare + Trakt-Kommentar-Bereich. + Details + Laufzeit, Status, Veröffentlichung, Sprache und verwandte Infos. + Episoden-Karten + Wähle, wie Episoden auf dem Metadaten-Bildschirm dargestellt werden. + Horizontal + Reihenkarten im Backdrop-Stil + Liste + Detailorientierte gestapelte Karten + Episoden + Staffeln und Episodenliste für Serien. + Gruppe %1$d + Mehr davon + Empfehlungs-Reihe. + Keine + Übersicht + Synopsis, Bewertungen, Genres und wichtigste Credits. + Produktion + Studios und Sender. + ERSCHEINUNGSBILD + ABSCHNITTE + Tab-Gruppe %1$d + Tab-Layout + Abschnitte wie in der TV-App in Tabs gruppieren. Weise bis zu 3 Abschnitte pro Tab-Gruppe zu. + Trailer + Trailer-Reihe und Wiedergabe-Verknüpfungen. + Benachrichtigungen sind in Nuvio derzeit deaktiviert. + Episode-Release-Benachrichtigungen + Lokale Benachrichtigungen planen, wenn eine neue Episode für eine gespeicherte Serie verfügbar wird. + System-Benachrichtigungen sind für Nuvio deaktiviert. Aktiviere sie, um Benachrichtigungen und Tests zu erhalten. + %1$d Release-Benachrichtigungen sind derzeit auf diesem Gerät geplant. + BENACHRICHTIGUNGEN + TEST + Test-Benachrichtigung senden + Test-Benachrichtigung wird gesendet... + Lokale Test-Benachrichtigung für %1$s senden. + Speichere zuerst eine Serie in deiner Bibliothek, um Benachrichtigungen zu testen. + Test-Benachrichtigung + Community + Sieh dir die Menschen an, die Nuvio auf Mobile, TV und Web entwickeln und unterstützen. + Supporters-API ist nicht konfiguriert. Füge DONATIONS_BASE_URL zu local.properties hinzu. + Mitwirkende + Unterstützer + GitHub öffnen + GitHub-Profil nicht verfügbar + Keine Nachricht angehängt. + Mitwirkende werden geladen... + Unterstützer werden geladen... + Mitwirkende konnten nicht geladen werden + Unterstützer konnten nicht geladen werden + Keine Mitwirkenden gefunden. + Keine Unterstützer gefunden. + Mitwirkende können nicht geladen werden. + Unterstützer können nicht geladen werden. + Mitwirkende können momentan nicht geladen werden. + Unterstützer können momentan nicht geladen werden. + %1$d Commits insgesamt + Jan + Feb + Mär + Apr + Mai + Jun + Jul + Aug + Sep + Okt + Nov + Dez + %1$s %2$s, %3$s + Alle Addons + Alle Plugins + Erlaubte Addons + Erlaubte Plugins + Anime Skip + AnimeSkip-Client-ID + Gib deine AnimeSkip-API-Client-ID ein. Erhältlich unter anime-skip.com. + Intro-Übermittlung aktivieren + Eine Schaltfläche anzeigen, um Intro-/Outro-Zeitstempel an die Community-Datenbank zu übermitteln. + IntroDB-API-Schlüssel + Gib deinen IntroDB-API-Schlüssel ein, um Zeitstempel zu übermitteln. Für die Übermittlung erforderlich. + Auch AnimeSkip nach Skip-Zeitstempeln durchsuchen (erfordert Client-ID). + Nächste Episode automatisch abspielen + Automatisch die nächste Episode finden und abspielen, wenn der Schwellenwert erreicht ist. + Nur Gerät + App bevorzugen (FFmpeg) + Gerät bevorzugen + Decoder-Priorität + Außerhalb tippen, um zu schließen + Außerhalb tippen, um zu speichern & zu schließen + %1$d Tag + %1$d Tage + %1$d Stunde + %1$d Stunden + libass aktivieren + libass für ASS/SSA-Untertitel-Rendering anstelle des Standard-Renderers verwenden. + Halte-Geschwindigkeit + Halten zum Beschleunigen + Halte irgendwo auf der Player-Oberfläche gedrückt, um die Wiedergabegeschwindigkeit vorübergehend zu erhöhen. + Ungültiges Regex-Muster + Cache-Dauer des letzten Links + DV7 zu HEVC umwandeln + Dolby Vision Profil 7 zu HEVC-Fallback für nicht unterstützte Geräte. + Minuten vor Ende + Karte für nächste Episode so viele Minuten vor dem Ende anzeigen. + %1$d Min. + Keine Einträge verfügbar + Nicht festgelegt + Standard + Gerätesprache + Erzwungen + Keine + Binge-Gruppe bevorzugen + Bei automatischer Wiedergabe einen Stream aus derselben Binge-Gruppe wie den aktuellen bevorzugen. + Bevorzugte Audiosprache + Bevorzugte Untertitelsprache + Voreinstellungen + Stimmt mit Stream-Name, Bezeichnung, Beschreibung, Addon und URL überein. + Regex-Muster + 4K|2160p|Remux + Beliebig 1080p+ + AVC / x264 + BluRay-Qualität + Dolby Atmos / DTS + Englisch + HDR / Dolby Vision + HEVC / x265 + Kein CAM/TS + Kein REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Kleiner + WEB-Quellen + Render-Typ + Standard (Cues) + Effekte Canvas + Effekte OpenGL + Overlay Canvas + Overlay OpenGL + Letzten Link wiederverwenden + Den letzten funktionierenden Stream für denselben Film/dieselbe Episode automatisch abspielen, solange der Cache gültig ist. + Zweite Audiosprache + Zweite Untertitelsprache + DECODER + NÄCHSTE EPISODE + PLAYER + ÜBERSPRINGEN-SEGMENTE + STREAM-AUTOPLAY + STREAM-AUSWAHL + UNTERTITEL UND AUDIO + UNTERTITEL-RENDERING + %1$d ausgewählt + Lade-Overlay anzeigen + Das einleitende Lade-Overlay anzeigen, während ein Stream startet. + Intro/Outro/Rückblick überspringen + Skip-Schaltfläche bei erkannten Intro-, Outro- und Rückblick-Segmenten anzeigen. + Quellbereich + Alle Addons + Streams aus allen installierten Addons berücksichtigen. + Alle Quellen + Streams aus Addons und Plugins berücksichtigen. + Nur aktivierte Plugins + Nur Streams aus aktivierten Plugins berücksichtigen. + Nur installierte Addons + Nur Streams aus installierten Addons berücksichtigen. + Stream-Auswahlmodus + Ersten verfügbaren Stream + Den ersten gefundenen Stream automatisch abspielen. + Manuell + Streams jedes Mal manuell auswählen. + Regex-Übereinstimmung + Automatisch einen Stream auswählen, der einem Regex-Muster entspricht. + Stream-Timeout + Wie lange auf Streams gewartet wird, bevor automatisch ausgewählt wird. + Minuten vor Ende + Schwellenwert-Modus + Minuten vor Ende + Prozent + Schwellenwert in Prozent + Karte für nächste Episode anzeigen, wenn die Wiedergabe diesen Prozentsatz erreicht. + %1$d% + Sofort + %1$ds + Unbegrenzt + Tunneled-Wiedergabe + Tunneled-Wiedergabe für niedrigere Latenz bei Audio-/Video-Synchronisation aktivieren. + Füge unten deinen eigenen TMDB-API-Schlüssel hinzu, bevor du die Anreicherung aktivierst. + TMDB-API-Schlüssel + TMDB-Anreicherung aktivieren + Verwende deinen TMDB-API-Schlüssel, um Addon-Metadaten auf dem Detail-Bildschirm anzureichern, wenn eine TMDB- oder IMDb-ID verfügbar ist. + Gib deinen TMDB-v3-API-Schlüssel ein. + Sprachcode + Artwork + Backdrop, Poster und Logo durch TMDB-Artwork ersetzen. + Grundinfos + TMDB-Titel, -Synopsis, -Genres und -Bewertung verwenden. + Sammlungen + Franchise- und Sammlungsreihen für Filme anzeigen, wenn TMDB sie bereitstellt. + Credits + TMDB-Schöpfer, -Regisseure, -Drehbuchautoren und -Besetzungsfotos verwenden. + Details + TMDB-Veröffentlichungsinfos, Laufzeit, Altersfreigabe, Status, Land und Sprache verwenden. + Episoden + TMDB-Episodentitel, -Thumbnails, -Beschreibungen und -Laufzeiten für Serien verwenden. + Mehr davon + TMDB-Empfehlungen am Ende der Detailseiten anzeigen. + Sender + TMDB-Sender-Metadaten für TV-Titel verwenden. + Produktionsfirmen + TMDB-Produktionsfirmen-Metadaten auf dem Detail-Bildschirm verwenden. + Staffel-Poster + TMDB-Staffel-Poster im Staffel-Auswahl-Bildschirm der Metadaten für Serien verwenden. + Trailer + TMDB-Trailer-Videos auf Detailseiten abrufen und anzeigen. + Persönlicher API-Schlüssel + Bevorzugte Sprache + Lege den TMDB-Sprachcode für lokalisierte Metadaten fest, zum Beispiel `de`, `de-DE` oder `en-US`. + ANMELDEDATEN + LOKALISIERUNG + MODULE + TMDB + Nach der Genehmigung wirst du automatisch zurückgeleitet. + AUTHENTIFIZIERUNG + Kommentare + Trakt-Kommentare auf Film- und Serien-Details anzeigen + Trakt verbinden + Verbunden als %1$s + Trakt-Benutzer + Trennen + Browser konnte nicht geöffnet werden + FUNKTIONEN + Trakt-Anmeldung in deinem Browser abschließen + Verfolge, was du anschaust, speichere in der Watchlist oder in benutzerdefinierten Listen und halte deine Bibliothek mit Trakt synchron. + Fehlende Trakt-Anmeldedaten in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Trakt-Login öffnen + Deine Speichern-Aktionen können jetzt auf Trakt-Watchlist und persönliche Listen abzielen. + Mit Trakt anmelden, um listenbasiertes Speichern und Trakt-Bibliotheksmodus zu aktivieren. + Publikumsbewertung + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Unbekannt + Bernstein + Karminrot + Smaragd + Ozean + Rose + Violett + Weiß + Nächste Episode + Quelle wird gesucht… + Abspielen über %1$s in %2$d… + Thumbnail der nächsten Episode + Noch nicht ausgestrahlt + Überspringen + Intro überspringen + Outro überspringen + Rückblick überspringen + Keine Untertitel gefunden + Afrikaans + Albanisch + Amharisch + Arabisch + Armenisch + Aserbaidschanisch + Baskisch + Weißrussisch + Bengalisch + Bosnisch + Bulgarisch + Birmanisch + Katalanisch + Chinesisch + Chinesisch (vereinfacht) + Chinesisch (traditionell) + Kroatisch + Tschechisch + Dänisch + Niederländisch + Englisch + Estnisch + Filipino + Finnisch + Französisch + Galicisch + Georgisch + Deutsch + Griechisch + Gujarati + Hebräisch + Hindi + Ungarisch + Isländisch + Indonesisch + Irisch + Italienisch + Japanisch + Kannada + Kasachisch + Khmer + Koreanisch + Laotisch + Lettisch + Litauisch + Mazedonisch + Malaiisch + Malayalam + Maltesisch + Marathi + Mongolisch + Nepalesisch + Norwegisch + Persisch + Polnisch + Portugiesisch (Portugal) + Portugiesisch (Brasilien) + Punjabi + Rumänisch + Russisch + Serbisch + Singhalesisch + Slowakisch + Slowenisch + Spanisch + Spanisch (Lateinamerika) + Swahili + Schwedisch + Tamil + Telugu + Thailändisch + Türkisch + Ukrainisch + Urdu + Usbekisch + Vietnamesisch + Walisisch + Zulu + Löschen + Fortfahren + Ignorieren + Installieren + Später + Nein + Aktualisieren + Ja + Möchtest du die App verlassen? + App verlassen + Dieser Katalog hat keine Einträge zurückgegeben. + Keine Titel gefunden + Überprüfe deine WLAN- oder Mobilfunkverbindung und versuche es erneut. + Regisseur + Laden fehlgeschlagen + Mehr davon + Staffeln + Dieses Addon hat Videos für die Serie zurückgegeben, aber keines enthielt Staffel- oder Episodennummern. + Dieses Addon hat keine Episoden-Metadaten für diese Serie bereitgestellt. + Episoden wurden von diesem Addon noch nicht veröffentlicht. + Dein Gerät ist online, aber Nuvio konnte die erforderlichen Server nicht erreichen. + Weniger anzeigen + Mehr anzeigen ▾ + Drehbuchautor + Alle Genres + Katalog + %1$s • %2$s + Der ausgewählte Katalog konnte keine Discover-Einträge zurückgeben. + Discover konnte nicht geladen werden + Installierte Addons bieten keine Board-kompatiblen Kataloge für Discover. + Keine Discover-Kataloge + Der ausgewählte Katalog und die Filter haben keine Einträge zurückgegeben. + Keine Titel gefunden + Installiere und überprüfe mindestens ein Addon, bevor du Discover-Kataloge durchsuchst. + Katalog auswählen + Genre auswählen + Typ auswählen + Typ + Vorherige als ungesehen markieren + Vorherige als gesehen markieren + %1$s als ungesehen markieren + %1$s als gesehen markieren + Als ungesehen markieren + Als gesehen markieren + Als Nächstes + %1$s gesehen + Installiere und überprüfe mindestens ein Addon, bevor Katalogreihen auf der Startseite geladen werden. + Installierte Addons bieten derzeit keine Board-kompatiblen Kataloge ohne erforderliche Extras. + Keine Startreihen verfügbar + Details ansehen + Wiedergabe- und Speichersteuerung. + Aktionen + Hauptbesetzungsliste. + Reihe für verwandte Sammlung oder Franchise. + Sammlung + Trakt-Kommentar-Bereich. + Laufzeit, Status, Veröffentlichung, Sprache und verwandte Infos. + Details + Staffeln und Episodenliste für Serien. + Empfehlungs-Reihe. + Mehr davon + Synopsis, Bewertungen, Genres und wichtigste Credits. + Übersicht + Studios und Sender. + Produktion + Trailer-Reihe und Wiedergabe-Verknüpfungen. + Wieder online + Server nicht erreichbar + Keine Internetverbindung + (%1$d Jahre) + Geboren am %1$s%2$s + Gestorben am %1$s + Bekannt für: %1$s + Neueste + Details für %1$s konnten nicht geladen werden + Beliebt + Etwas ist schiefgelaufen + Demnächst + Rücktaste + Abbrechen + PIN eingeben + PIN für %1$s eingeben + PIN vergessen? + Falsche PIN + Gesperrt. Versuche es in %1$ds erneut + Avatar-Optionen erscheinen hier, wenn der Katalog geladen wird. + Avatar: %1$s + Avatar auswählen + Wähle unten einen Avatar. + Profil erstellen + Alle Daten für "%1$s" werden dauerhaft gelöscht. + Profil löschen + Profil hinzufügen + Profil bearbeiten + Aktuelle PIN eingeben + Neue PIN eingeben + Profil %1$d + Avatare werden geladen... + Profile verwalten + Profilname + Neues Profil + Primäre Addons aus + Primäre Addons ein + PIN für %1$s entfernen + PIN-Sperre entfernen + Wird gespeichert... + Sicherheit + Füge eine PIN hinzu, wenn dieses Profil vor dem Wechsel gesperrt sein soll. + Dieses Profil ist mit einer PIN geschützt. + Wähle einen Avatar für dieses Profil. + PIN-Sperre einrichten + Unbenanntes Profil + Primäre Addons verwenden + Das Addon-Setup des Hauptprofils mitverwenden, statt eine separate Liste zu pflegen. + Wer schaut? + Heruntergeladen + Fortsetzen + Aktive Scraper + Weitere Addons werden geprüft… + Stream-Link kopieren + Datei herunterladen + Die installierten Stream-Addons konnten keine gültige Stream-Antwort zurückgeben. + Streams konnten nicht geladen werden + Installiere zuerst ein Addon, um Streams für diesen Titel zu laden. + Deine installierten Addons bieten keine Streams für diesen Titeltyp. + Kein Stream-Addon verfügbar + Keines deiner installierten Addons hat Streams für diesen Titel zurückgegeben. + S%1$d E%2$d + Episode + S%1$dE%2$d - %3$s + Wird abgerufen… + Quelle wird gesucht… + Streams werden gesucht… + Stream-Link kopiert + Kein direkter Stream-Link verfügbar + Keine Metadaten verfügbar + Streams aktualisieren + Fortsetzen ab %1$d% + Fortsetzen ab %1$s + GRÖSSE %1$s + Trailer schließen + Trailer kann nicht abgespielt werden + Trakt-Listen konnten nicht geladen werden + Trakt-Listen konnten nicht aktualisiert werden + %1$s • %2$s + Update-Prüfung fehlgeschlagen + Download fehlgeschlagen + Wird heruntergeladen %1$d% + Installation kann nicht gestartet werden + Du verwendest die neueste Version. + Aktiviere App-Installationen für Nuvio und komm dann zurück, um fortzufahren. + Update wird heruntergeladen... + Keine Updates gefunden. + Eine neue Version ist bereit zur Installation. + In-App-Updates sind in diesem Build nicht verfügbar. + Download wird vorbereitet + Versionshinweise + Installationen erlauben, um fortzufahren + Update verfügbar + Update-Status + Dieses Addon ist bereits installiert. + Gib eine gültige Addon-URL ein + Manifest konnte nicht geladen werden + Nuvio + Kontolöschung fehlgeschlagen + Anmeldung fehlgeschlagen + Abmeldung fehlgeschlagen + Registrierung fehlgeschlagen + Katalogeinträge können nicht geladen werden. + Als Nächstes + Als Nächstes • S%1$dE%2$d + %1$s-Logo + Kommentare konnten nicht geladen werden + Details konnten von keinem Addon geladen werden. + Sender + Kein Addon liefert Metadaten für diesen Inhalt. + Download fehlgeschlagen + Zeigt Live-Download-Fortschritt und -Steuerung an. + Downloads + Download abgeschlossen + Wird heruntergeladen %1$s • %2$s + Wird heruntergeladen %1$s • %2$s / %3$s + Download fehlgeschlagen + Pausiert %1$s + Entfernen + %1$s aus deiner Bibliothek entfernen? + Aus Bibliothek entfernen? + Film + Benachrichtigungen, wenn eine neue Episode einer gespeicherten Serie veröffentlicht wird. + Vorschau einer Episoden-Release-Benachrichtigung. + Test-Benachrichtigung konnte nicht gesendet werden. + Test-Benachrichtigung für %1$s gesendet. + Dieser Stream kann nicht abgespielt werden. + Diese Profil-PIN wurde geändert. Verbinde dich einmal, um die Sperre auf diesem Gerät zu aktualisieren. + PIN-Sperre konnte nicht entfernt werden. Versuche es erneut. + Verbinde dich mit dem Internet, um die PIN-Sperre zu entfernen. + Diese PIN kann auf diesem Gerät noch nicht offline überprüft werden. Verbinde dich einmal und entsperre sie zuerst online. + PIN konnte nicht gesetzt werden. Versuche es erneut. + Verbinde dich mit dem Internet, um eine PIN festzulegen. + Dieses Profil verwendet primäre Addons. + %1$s konnte nicht geladen werden + Stream + Eingebettet + Autorisierung verweigert + Trakt-Anmeldung in deinem Browser abschließen + Ungültiger Trakt-Callback + Ungültiger Trakt-Callback-Status + Ungültige Trakt-Token-Antwort + Trakt-Bibliothek konnte nicht geladen werden + Liste %1$d + Trakt hat keinen Autorisierungscode zurückgegeben + Fehlende Trakt-Anmeldedaten + Trakt-Fortschritt konnte nicht geladen werden + Trakt-Anmeldung konnte nicht abgeschlossen werden + Trakt-Benutzer + Watchlist + Trailer + Unbekannt + Addon + Gespeichert + %1$s abspielen + %1$s fortsetzen + JSON ist leer. + Sammlung %1$d hat eine leere ID. + Sammlung '%1$s' hat einen leeren Titel. + Ordner %1$d in '%2$s' hat eine leere ID. + Ordner '%1$s' in '%2$s' hat einen leeren Titel. + Quelle %1$d in Ordner '%2$s' hat leere Felder. + Quelle %1$d in Ordner '%2$s' fehlt eine Trakt-Listen-ID. + Ungültiges JSON: %1$s + Addon nicht gefunden: %1$s + Januar + Februar + März + April + Mai + Juni + Juli + August + September + Oktober + November + Dezember + Jan + Feb + Mär + Apr + Mai + Jun + Jul + Aug + Sep + Okt + Nov + Dez + Produktionsfirma + Sender + %1$s konnte nicht geladen werden + Beliebt + Aktuell + %1$s • %2$s + Bestbewertet + Altersfreigabe + Filmdetails + Originalsprache + Herkunftsland + Veröffentlichungsinfo + Laufzeit + Poster + Text + Seriendetails + Status + Videos + DATEI + Kein direkter Stream-Link verfügbar + Vorherigen Download ersetzt + Download gestartet + Nicht unterstütztes Stream-Format für Downloads + Leerer Antwortinhalt + Anfrage mit HTTP %1$d fehlgeschlagen + Download-System ist nicht initialisiert + Download-Anfrage fehlgeschlagen + %1$s - %2$s + Gespeicherte Titel erscheinen hier, nachdem du auf einer Detailseite auf Speichern getippt hast. + Deine Bibliothek ist leer + Bibliothek konnte nicht geladen werden + Andere + Bibliothek + Verbinde Trakt und speichere Titel in deiner Watchlist oder in persönlichen Listen. + Deine Trakt-Bibliothek ist leer + Trakt-Bibliothek konnte nicht geladen werden + Trakt-Bibliothek + Anime + Kanäle + Filme + Serien + TV + %1$s ist jetzt verfügbar + %1$s • %2$s ist jetzt verfügbar + Eine neue Episode ist jetzt verfügbar + %1$s ist jetzt verfügbar + Episoden-Veröffentlichungen + Creator + Regisseur + Drehbuchautor + Publikumsbewertung + Kein abspielbarer Trailer-Stream gefunden. + Staffel %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index f107fc43..71116267 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1,4 +1,3 @@ - Reconnaissance et crédits du projet Retour @@ -111,29 +110,38 @@ Production Chaîne Collection + Personne + Réalisateur Personnalisé Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée. Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL. Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre. Liste publique TMDB ID de chaîne ID de collection + ID de personne Nom, ID ou URL de société de production ID ou URL TMDB https://www.themoviedb.org/list/8504994 ou 8504994 213 pour Netflix, 49 pour HBO, 2739 pour Disney+ 10 pour Star Wars Collection Marvel Studios, 420 ou URL de société + 31 pour Tom Hanks, ou URL de personne Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739. Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. + Exemple : https://www.themoviedb.org/person/31-tom-hanks ou 31. Titre affiché Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source. Films Marvel, Originaux Netflix, Pixar + Films avec Tom Hanks, Acteurs favoris + Films de Christopher Nolan, Réalisateurs favoris Meilleurs films d'action, drames coréens, animation 2024 Résultats de recherche Collection TMDB @@ -180,6 +188,27 @@ Préréglages Rechercher Ajouter une source + Ajouter une liste Trakt + Modifier la liste Trakt + Listes Trakt + Liste Trakt + Rechercher un titre, URL Trakt ou ID de liste + Utilisez une URL publique de liste Trakt ou un ID numérique de liste, ou recherchez par nom. + Programme du week-end, Lauréats + Résultats de recherche + Listes tendances + Listes populaires + Ordre + Croissant + Décroissant + Ordre de la liste + Ajoutés récemment + Titre + Date de sortie + Durée + Populaire + Pourcentage + Votes Action Aventure Animation @@ -213,6 +242,7 @@ Disney+ Prime Video Hulu + Original Populaire Mieux notés Récent @@ -220,6 +250,8 @@ Collection de films TMDB Production Chaîne + Personne + Réalisateur Découverte TMDB Créez-en une pour organiser vos catalogues. Aucune collection @@ -251,7 +283,7 @@ Connectez-vous pour accéder à votre bibliothèque et votre progression Se connecter Inscrivez-vous pour synchroniser vos données entre appareils - S\'inscrire + S'inscrire Vos données seront uniquement stockées localement Regardez tout, partout Bon retour @@ -314,11 +346,11 @@ Ajouter un profil Effacer la recherche Découvrir - Les addons installés n'ont retourné aucun résultat de recherche valide. + Les addons installés n'ont renvoyé aucun résultat de recherche valide. La recherche a échoué Installez et validez au moins un addon avant de rechercher. - Aucun addon active - Les catalogues installés n'ont retourné aucun résultat pour cette requête. + Aucun addon actif + Les catalogues installés n'ont renvoyé aucun résultat pour cette requête. Aucun résultat trouvé Vos addons installés n'exposent pas de catalogue de recherche. Aucun catalogue de recherche @@ -448,7 +480,7 @@ Visible Lecteur, sous-titres et lecture automatique Rayon de carte - STYLE DE CARTE D\'AFFICHE + STYLE DE CARTE D'AFFICHE Largeur de carte Personnalisé Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. @@ -552,7 +584,7 @@ Notification de test Communauté Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. - L\'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + L'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. Contributeurs Supporters Ouvrir GitHub @@ -582,7 +614,7 @@ Nov Déc %1$s %2$s %3$s - Toutes les addons + Tous les addons Tous les plugins Addons autorisés Plugins autorisés @@ -628,7 +660,7 @@ Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL. Modèle regex 4K|2160p|Remux - N\'importe quel 1080p+ + N'importe quel 1080p+ AVC / x264 Qualité BluRay Dolby Atmos / DTS @@ -665,8 +697,8 @@ Passer l'intro/outro/récap Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. Périmètre des sources - Toutes les addons - Considérer les streams de toutes les addons installés. + Tous les addons + Considérer les streams de tous les addons installés. Toutes les sources Considérer les streams des addons et des plugins. Plugins activés uniquement @@ -858,14 +890,14 @@ Oui Voulez-vous quitter l'application ? Quitter l'application - Ce catalogue n'a retourné aucun élément. + Ce catalogue n'a renvoyé aucun élément. Aucun titre trouvé Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. Réalisateur Échec du chargement Plus comme ceci Saisons - Cet addon a retourné des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. + Cet addon a renvoyé des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. Cet addon n'a fourni aucune métadonnée d'épisode pour cette série. Cet addon n'a pas encore publié d'épisodes. Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. @@ -875,11 +907,11 @@ Tous les genres Catalogue %1$s • %2$s - Le catalogue sélectionné n'a retourné aucun élément de découverte. + Le catalogue sélectionné n'a renvoyé aucun élément de découverte. Impossible de charger Découvrir Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. Aucun catalogue de découverte - Le catalogue et les filtres sélectionnés n'ont retourné aucun élément. + Le catalogue et les filtres sélectionnés n'ont renvoyé aucun élément. Aucun titre trouvé Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir. Sélectionner un catalogue @@ -960,7 +992,7 @@ Sélectionnez un avatar pour ce profil. Configurer le verrouillage PIN Profil sans nom - Utiliser les addons principales + Utiliser les addons principaux Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée. Qui regarde ? Téléchargé @@ -969,12 +1001,12 @@ Vérification d'autres addons… Copier le lien du stream Télécharger le fichier - Les addons de streams installés n'ont pas retourné de réponse valide. + Les addons de streams installés n'ont pas renvoyé de réponse valide. Impossible de charger les streams Installez d'abord un addon pour charger les streams de ce titre. Vos addons installés ne fournissent pas de streams pour ce type de titre. Aucun addon de streams disponible - Aucune de vos addons installés n'a retourné de streams pour ce titre. + Aucun de vos addons installés n'a renvoyé de stream pour ce titre. S%1$d E%2$d Épisode S%1$dE%2$d - %3$s @@ -1047,7 +1079,7 @@ Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord. Impossible de définir le code PIN. Veuillez réessayer. Connectez-vous à Internet pour définir un code PIN. - Ce profil utilise les addons principales. + Ce profil utilise les addons principaux. Impossible de charger %1$s Source Intégré @@ -1058,7 +1090,7 @@ Réponse de jeton Trakt invalide Impossible de charger la bibliothèque Trakt Liste %1$d - Trakt n'a pas retourné de code d'autorisation + Trakt n'a pas renvoyé de code d'autorisation Identifiants Trakt manquants Impossible de charger la progression Trakt Impossible de terminer la connexion Trakt @@ -1071,11 +1103,12 @@ Lire %1$s Reprendre %1$s Le JSON est vide. - La collection %1$d a un ID vide. - La collection \'%1$s' a un titre vide. - Le dossier %1$d dans \'%2$s' a un ID vide. - Le dossier \'%1$s\' dans \'%2$s\' a un titre vide. - La source %1$d dans le dossier \'%2$s\' a des champs vides. + La collection '%1$d' a un ID vide. + La collection '%1$s' a un titre vide. + Le dossier '%1$d' dans '%2$s' a un ID vide. + Le dossier '%1$s' dans '%2$s' a un titre vide. + La source '%1$d' dans le dossier '%2$s' a des champs vides. + La source '%1$d' dans le dossier '%2$s' n'a pas d'ID de liste Trakt. JSON invalide : %1$s Addon introuvable : %1$s Janvier diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 7cbb3f3e..43f04cb0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -13,7 +13,7 @@ Previous Remove Reorder - Reset + Reset to Default Resume Retry Save @@ -361,36 +361,36 @@ General Account Addons - Appearance + Layout Content & Discovery Continue Watching - Homescreen + Home Layout Integrations MDBList Ratings - Meta Screen + Detail Page Notifications Playback Plugins - Poster Customization + Poster Card Style Settings Supporters & Contributors TMDB Enrichment Trakt ABOUT - Manage your account, sign out, or delete. + Account and sync status ACCOUNT - Tune home presentation and visual preferences. - Check for new versions of the app. + Home structure and poster styles + Download latest release Check for updates Manage addons and discovery sources. Manage your downloaded movies and episodes. Downloads GENERAL - Connect TMDB and MDBList services. + Manage available integrations Manage episode release alerts and send a test notification. Change to a different profile. Switch Profile - Connect Trakt, sync watchlist lists, and save titles directly to Trakt. + Open Trakt connection screen Loading your Trakt lists… Choose where to save this title on Trakt Donate @@ -443,13 +443,13 @@ Sign Out? Status Anonymous - Signed In + Signed in AMOLED Black Use pure black backgrounds for OLED screens. App Language Choose Language - Show, hide, and style the Continue Watching shelf. - Adjust shared poster card width and corner radius presets. + Settings for the Continue Watching section. + Tune card width and corner radius. DISPLAY HOME THEME @@ -470,22 +470,23 @@ CATALOGS CATALOGS & COLLECTIONS COLLECTIONS - HERO - HERO SOURCES + Home Layout + Hero Catalogs %1$d of %2$d selected - Show Hero - Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below. + Show Hero Section + Display hero carousel at top of home. %1$d of %2$d catalogs visible • %3$d hero sources selected Open a catalog only when you need to rename or reorder it. Visible + Hide value Player, subtitles, and auto-play - Card Radius - POSTER CARD STYLE - Card Width + Corner Radius + Poster Card Style + Width Custom - Customize card width and corner radius for shared poster cards across the app. + Tune card width and corner radius. Hide labels - Landscape mode for shelf posters + Landscape Posters Live Preview %1$s (%2$s) Corner radius: %1$ddp @@ -502,9 +503,10 @@ Dense Large Standard + Show value Show a popup to continue where you left off when opening the app after leaving from the player. Resume prompt on launch - CARD STYLE + Poster Card Style ON LAUNCH UP NEXT BEHAVIOR VISIBILITY @@ -514,27 +516,27 @@ Artwork-first poster card Wide Info-dense horizontal card - When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. Useful if you rewatch earlier episodes. - Up Next from furthest episode + Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. + Up Next From Furthest Episode HOME SOURCES Install, remove, refresh, and sort your content sources. Install JavaScript scraper repositories and test providers internally. - Control which catalogs appear on Home and in what order. - Disable detail sections and reorder everything below Hero. + Adjust home layout, content visibility, and poster behavior + Settings for the detail and episode screens. Create custom catalog groupings with folders shown on Home. - INTEGRATIONS - Enhance detail pages with TMDB artwork, credits, episode metadata, and more. - Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages. + Integrations + Metadata enrichment controls + External ratings providers Add your MDBList API key below before turning ratings on. - Get a key from https://mdblist.com/preferences and paste it here. - API key - MDBList API key - Enable MDBList ratings - Show external ratings from MDBList on metadata pages when an IMDb ID is available. - API KEY - RATING PROVIDERS - MDBLIST + Required to fetch ratings from MDBList + API Key + API Key + Enable MDBList Ratings + Fetch ratings from external providers in metadata detail screen + API Key + External ratings providers + MDBList Ratings Actions Play and save controls. Cast @@ -544,7 +546,7 @@ Collection Related collection or franchise rail. Comments - Trakt comments section. + Reviews from Trakt Details Runtime, status, release, language, and related info. Episode Cards @@ -556,8 +558,8 @@ Episodes Seasons and episode list for series. Group %1$d - More Like This - Recommendation rail. + More like this + TMDB recommendation backdrops on detail page None Overview Synopsis, ratings, genres, and core credits. @@ -614,8 +616,8 @@ Nov Dec %1$s %2$s, %3$s - All Addons - All Plugins + All installed addons + All enabled plugins Allowed Addons Allowed Plugins Anime Skip @@ -626,11 +628,11 @@ IntroDB API Key Enter your IntroDB API key to submit timestamps. Required for submission. Also search AnimeSkip for skip timestamps (requires client ID). - Auto-Play Next Episode - Automatically find and play the next episode when the threshold is reached. - Device Only - Prefer App (FFmpeg) - Prefer Device + Auto-play Next Episode + Start next episode automatically when prompt appears. + Device decoders only + Prefer app decoders (FFmpeg) + Prefer device decoders Decoder Priority Tap outside to close Tap outside to save & close @@ -638,32 +640,32 @@ %1$d days %1$d hour %1$d hours - Enable libass - Use libass for ASS/SSA subtitle rendering instead of the default renderer. + Use libass for ASS/SSA subtitles + Experimental: advanced ASS/SSA rendering (styles, positioning, animations) Hold Speed Hold To Speed Long-press anywhere on the player surface to temporarily boost playback speed. Invalid regex pattern Last Link Cache Duration - Map DV7 to HEVC - Dolby Vision Profile 7 to HEVC fallback for unsupported devices. - Minutes Before End - Show next episode card this many minutes before the end. + DV7 - HEVC Fallback + Map Dolby Vision Profile 7 to standard HEVC for devices without DV hardware support + Threshold Minutes + Fallback when no outro timestamp exists. %1$d min No items available Not set - Default - Device Language + Default (media file) + Device language Forced None - Prefer Binge Group - When auto-playing, prefer a stream from the same binge group as the current one. + Prefer Binge Group (Next Episode) + Try the same source profile first (same addon/quality group) before normal auto-play rules. Preferred Audio Language - Preferred Subtitle Language + Preferred Language Presets - Matches against stream name, label, description, addon, and URL. + Matches against stream name/title/description/addon/url. Example: 4K|2160p|Remux Regex Pattern - 4K|2160p|Remux + No pattern set. Example: 4K|2160p|Remux Any 1080p+ AVC / x264 BluRay Quality @@ -677,16 +679,16 @@ 4K / Remux 720p / Smaller WEB Sources - Render Type - Standard (Cues) + Libass Render Mode + Standard Cues Effects Canvas Effects OpenGL Overlay Canvas - Overlay OpenGL + Overlay OpenGL (Recommended) Reuse Last Link - Auto-play your last working stream for this same movie/episode when cache is still valid. + Auto-play your last working stream for this same movie/episode when cache is still valid Secondary Audio Language - Secondary Subtitle Language + Secondary Preferred Language DECODER NEXT EPISODE PLAYER @@ -696,79 +698,79 @@ SUBTITLE AND AUDIO SUBTITLE RENDERING %1$d selected - Show Loading Overlay - Show the opening loading overlay while a stream starts playing. - Skip Intro/Outro/Recap - Show skip button during detected intro, outro, and recap segments. - Source Scope - All Addons - Consider streams from all installed addons. - All Sources - Consider streams from both addons and plugins. - Enabled Plugins Only - Only consider streams from enabled plugins. - Installed Addons Only - Only consider streams from installed addons. - Stream Selection Mode - First Available Stream - Automatically play the first stream found. - Manual - Select streams manually each time. - Regex Match - Auto-select a stream matching a regex pattern. - Stream Timeout - How long to wait for streams before auto-selecting. - Minutes Before End - Threshold Mode - Minutes Before End + Loading Overlay + Show loading screen until first video frame appears. + Skip Intro + Use introdb.app to detect intros and recaps. + Auto-play Source Scope + All installed addons + Auto-play only considers streams coming from your installed addons. + All sources + Auto-play can use both installed addons and enabled plugins. + Enabled plugins only + Auto-play only considers streams coming from enabled plugins. + Installed addons only + Auto-play only considers streams coming from your installed addons. + Auto Stream Selection + Auto-play first source + Play the first available source automatically. + Manual (choose stream) + Always show source list and let me choose. + Auto-play regex match + Play first source whose text matches your regex pattern. + Stream Selection Timeout + Wait time for addons before selecting. + Threshold Minutes + Next Episode Threshold Mode + Minutes before end Percentage Threshold Percentage - Show next episode card when playback reaches this percentage. + Fallback when no outro timestamp exists. %1$d% Instant %1$ds Unlimited Tunneled Playback - Enable tunneled playback for lower latency audio/video sync. + Hardware-level audio/video sync. May improve playback on some Android TV devices Add your own TMDB API key below before turning enrichment on. - TMDB API key - Enable TMDB enrichment - Use your TMDB API key to enrich addon metadata on the details screen when a TMDB or IMDb ID is available. + API Key + Enable TMDB Enrichment + Use TMDB as a metadata source to enhance addon data Enter your TMDB v3 API key. Language code Artwork - Replace backdrop, poster, and logo with TMDB artwork. - Basic info - Use TMDB title, synopsis, genres, and rating. + Logo and backdrop images from TMDB + Basic Info + Description, genres, and rating from TMDB Collections - Show franchise and collection rails for movies when TMDB provides them. + TMDB movie collections in release order Credits - Use TMDB creators, directors, writers, and cast photos. + Cast with photos, director, and writer from TMDB Details - Use TMDB release info, runtime, age rating, status, country, and language. + Runtime, status, country, and language from TMDB Episodes - Use TMDB episode titles, thumbnails, descriptions, and runtimes for series. - More like this - Show TMDB recommendations at the bottom of detail pages. + Episode titles, overviews, thumbnails, and runtime from TMDB + More Like This + TMDB recommendation backdrops on detail page Networks - Use TMDB network metadata for TV titles. - Production companies - Use TMDB production company metadata on the details screen. + Networks with logos from TMDB + Productions + Production companies from TMDB Season posters Use TMDB season posters in the metadata screen season selector for series. Trailers - Fetch and show TMDB trailer videos section on detail pages. + Trailer candidates from TMDB videos for the detail trailer section Personal API key - Preferred language - Set the TMDB language code used for localized metadata, for example `en`, `en-US`, or `pt-BR`. + Language + TMDB metadata language for title, logo, and enabled fields CREDENTIALS LOCALIZATION MODULES - TMDB + TMDB Enrichment After approval, you will be redirected back automatically. AUTHENTICATION Comments - Show Trakt comments on movie and show details + Show Trakt reviews on metadata pages Connect Trakt Connected as %1$s Trakt user @@ -776,7 +778,7 @@ Failed to open browser FEATURES Finish Trakt sign in in your browser - Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt. + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. @@ -1024,6 +1026,7 @@ Resume from %1$d% Resume from %1$s SIZE %1$s + Torrent streams are not supported Close trailer Unable to play trailer Failed to load Trakt lists diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 3a6a6013..ba9080d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -62,7 +62,7 @@ data class CollectionSource( addonId = sourceAddonId, type = sourceType, catalogId = sourceCatalogId, - genre = genre, + genre = genre.normalizedOptionalGenre(), ) } } @@ -193,7 +193,7 @@ data class CollectionFolder( addonId = source.addonId, type = source.type, catalogId = source.catalogId, - genre = source.genre, + genre = source.genre.normalizedOptionalGenre(), ) } } @@ -217,6 +217,11 @@ data class Collection( get() = FolderViewMode.fromString(viewMode) } +private fun String?.normalizedOptionalGenre(): String? = + this + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("none", ignoreCase = true) } + data class AvailableCatalog( val addonId: String, val addonName: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index b76fdf7c..d09907ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -358,7 +358,7 @@ private fun HeroContentBlock( modifier = Modifier .fillMaxWidth(layout.logoWidthFraction) .aspectRatio(2.6f) - .clickable(enabled = !layout.isTablet && onItemClick != null) { + .clickable(enabled = onItemClick != null) { onItemClick?.invoke(item) }, alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, @@ -369,7 +369,7 @@ private fun HeroContentBlock( text = item.name, modifier = Modifier .fillMaxWidth() - .clickable(enabled = !layout.isTablet && onItemClick != null) { + .clickable(enabled = onItemClick != null) { onItemClick?.invoke(item) }, style = if (layout.isTablet) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt index b60ccbec..024b0e50 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -19,7 +19,7 @@ data class PlayerAudioLevel( expect fun LockPlayerToLandscape() @Composable -expect fun EnterImmersivePlayerMode() +expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) @Composable expect fun ManagePlayerPictureInPicture( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index a99d0be5..e19e11b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -139,7 +139,6 @@ fun PlayerScreen( initialProgressFraction: Float? = null, ) { LockPlayerToLandscape() - EnterImmersivePlayerMode() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState @@ -195,6 +194,9 @@ fun PlayerScreen( var playerController by remember { mutableStateOf(null) } var playerControllerSourceUrl by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } + val keepScreenAwake = errorMessage == null && + (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading)) + EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) var scrubbingPositionMs by remember { mutableStateOf(null) } var pausedOverlayVisible by remember { mutableStateOf(false) } var gestureFeedback by remember { mutableStateOf(null) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index f47fb7b8..025d6acc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.settings import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_french +import nuvio.composeapp.generated.resources.lang_german import nuvio.composeapp.generated.resources.lang_spanish import nuvio.composeapp.generated.resources.lang_portuguese_portugal import nuvio.composeapp.generated.resources.lang_turkish @@ -17,6 +18,7 @@ enum class AppLanguage( ) { ENGLISH("en", Res.string.lang_english), FRENCH("fr", Res.string.lang_french), + GERMAN("de", Res.string.lang_german), SPANISH("es", Res.string.lang_spanish), PORTUGUESE("pt", Res.string.lang_portuguese_portugal), TURKISH("tr", Res.string.lang_turkish), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt index 9787b732..3a7a2c40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt @@ -6,11 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,7 +16,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettings @@ -170,22 +166,13 @@ private fun MdbListApiKeyRow( ) } - OutlinedTextField( + SettingsSecretTextField( value = draft, onValueChange = { draft = it }, modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(Res.string.settings_mdb_api_key_label)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), + label = stringResource(Res.string.settings_mdb_api_key_label), ) Row(modifier = Modifier.fillMaxWidth()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 681383f3..40c2dfb6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -1960,27 +1960,16 @@ private fun IntroDbApiKeyDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (errorMessage != null) 1f else 0.3f)), - ) { - BasicTextField( - value = value, - onValueChange = { - value = it - errorMessage = null - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - textStyle = MaterialTheme.typography.bodyLarge.copy( - color = MaterialTheme.colorScheme.onSurface, - ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - singleLine = true, - ) - } + SettingsSecretTextField( + value = value, + onValueChange = { + value = it + errorMessage = null + }, + label = stringResource(Res.string.settings_playback_introdb_api_key), + modifier = Modifier.fillMaxWidth(), + isError = errorMessage != null, + ) if (errorMessage != null) { Text( text = errorMessage!!, @@ -2162,4 +2151,3 @@ private fun libassRenderTypeRes(renderType: String): StringResource = when (rend @Composable private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType)) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 94ec9cd3..6c80adb8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -16,9 +16,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -30,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -296,118 +298,121 @@ private fun MobileSettingsScreen( onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { - NuvioScreen { - stickyHeader { - val previousPage = page.previousPage() - NuvioScreenHeader( - title = stringResource(page.titleRes), - onBack = previousPage?.let { { onPageChange(it) } }, - ) - } + val saveableStateHolder = rememberSaveableStateHolder() + saveableStateHolder.SaveableStateProvider(page.name) { + NuvioScreen { + stickyHeader { + val previousPage = page.previousPage() + NuvioScreenHeader( + title = stringResource(page.titleRes), + onBack = previousPage?.let { { onPageChange(it) } }, + ) + } - when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = false, - onPlaybackClick = { onPageChange(SettingsPage.Playback) }, - onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, - onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, - onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = onSupportersContributorsClick, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSwitchProfileClick = onSwitchProfile, - ) - SettingsPage.Account -> accountSettingsContent( - isTablet = false, - ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = false, - ) - SettingsPage.Playback -> playbackSettingsContent( - isTablet = false, - showLoadingOverlay = showLoadingOverlay, - holdToSpeedEnabled = holdToSpeedEnabled, - holdToSpeedValue = holdToSpeedValue, - preferredAudioLanguage = preferredAudioLanguage, - secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, - streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, - decoderPriority = decoderPriority, - mapDV7ToHevc = mapDV7ToHevc, - tunnelingEnabled = tunnelingEnabled, - useLibass = useLibass, - libassRenderType = libassRenderType, - ) - SettingsPage.Appearance -> appearanceSettingsContent( - isTablet = false, - selectedTheme = selectedTheme, - onThemeSelected = onThemeSelected, - amoledEnabled = amoledEnabled, - onAmoledToggle = onAmoledToggle, - selectedAppLanguage = selectedAppLanguage, - onAppLanguageSelected = onAppLanguageSelected, - onContinueWatchingClick = onContinueWatchingClick, - onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, - ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = false, - uiState = episodeReleaseNotificationsUiState, - ) - SettingsPage.ContinueWatching -> continueWatchingSettingsContent( - isTablet = false, - isVisible = continueWatchingPreferencesUiState.isVisible, - style = continueWatchingPreferencesUiState.style, - upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, - showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, - ) - SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( - isTablet = false, - uiState = posterCardStyleUiState, - ) - SettingsPage.ContentDiscovery -> contentDiscoveryContent( - isTablet = false, - showPluginsEntry = AppFeaturePolicy.pluginsEnabled, - onAddonsClick = onAddonsClick, - onPluginsClick = onPluginsClick, - onHomescreenClick = onHomescreenClick, - onMetaScreenClick = onMetaScreenClick, - onCollectionsClick = onCollectionsClick, - ) - SettingsPage.Addons -> addonsSettingsContent() - SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() - SettingsPage.Homescreen -> homescreenSettingsContent( - isTablet = false, - heroEnabled = homescreenHeroEnabled, - items = homescreenItems, - ) - SettingsPage.MetaScreen -> metaScreenSettingsContent( - isTablet = false, - uiState = metaScreenSettingsUiState, - ) - SettingsPage.Integrations -> integrationsContent( - isTablet = false, - onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, - onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, - ) - SettingsPage.TmdbEnrichment -> tmdbSettingsContent( - isTablet = false, - settings = tmdbSettings, - ) - SettingsPage.MdbListRatings -> mdbListSettingsContent( - isTablet = false, - settings = mdbListSettings, - ) - SettingsPage.TraktAuthentication -> traktSettingsContent( - isTablet = false, - uiState = traktAuthUiState, - commentsEnabled = traktCommentsEnabled, - onCommentsEnabledChange = TraktCommentsSettings::setEnabled, - ) + when (page) { + SettingsPage.Root -> settingsRootContent( + isTablet = false, + onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, + onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, + onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, + onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = onSupportersContributorsClick, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = onAccountClick, + onSwitchProfileClick = onSwitchProfile, + ) + SettingsPage.Account -> accountSettingsContent( + isTablet = false, + ) + SettingsPage.SupportersContributors -> supportersContributorsContent( + isTablet = false, + ) + SettingsPage.Playback -> playbackSettingsContent( + isTablet = false, + showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, + streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, + streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + decoderPriority = decoderPriority, + mapDV7ToHevc = mapDV7ToHevc, + tunnelingEnabled = tunnelingEnabled, + useLibass = useLibass, + libassRenderType = libassRenderType, + ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = false, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, + onContinueWatchingClick = onContinueWatchingClick, + onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, + ) + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = false, + uiState = episodeReleaseNotificationsUiState, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = false, + isVisible = continueWatchingPreferencesUiState.isVisible, + style = continueWatchingPreferencesUiState.style, + upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = false, + uiState = posterCardStyleUiState, + ) + SettingsPage.ContentDiscovery -> contentDiscoveryContent( + isTablet = false, + showPluginsEntry = AppFeaturePolicy.pluginsEnabled, + onAddonsClick = onAddonsClick, + onPluginsClick = onPluginsClick, + onHomescreenClick = onHomescreenClick, + onMetaScreenClick = onMetaScreenClick, + onCollectionsClick = onCollectionsClick, + ) + SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() + SettingsPage.Homescreen -> homescreenSettingsContent( + isTablet = false, + heroEnabled = homescreenHeroEnabled, + items = homescreenItems, + ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = false, + uiState = metaScreenSettingsUiState, + ) + SettingsPage.Integrations -> integrationsContent( + isTablet = false, + onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + ) + SettingsPage.TmdbEnrichment -> tmdbSettingsContent( + isTablet = false, + settings = tmdbSettings, + ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = false, + settings = mdbListSettings, + ) + SettingsPage.TraktAuthentication -> traktSettingsContent( + isTablet = false, + uiState = traktAuthUiState, + commentsEnabled = traktCommentsEnabled, + onCommentsEnabledChange = TraktCommentsSettings::setEnabled, + ) + } } } } @@ -468,6 +473,8 @@ private fun TabletSettingsScreen( onPageChange(page) } + val saveableStateHolder = rememberSaveableStateHolder() + Row(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier @@ -510,134 +517,138 @@ private fun TabletSettingsScreen( } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 40.dp, - top = topOffset, - end = 40.dp, - bottom = 40.dp, - ), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - item { - val previousPage = page.previousPage() - TabletPageHeader( - title = if (page == SettingsPage.Root) { - stringResource(activeCategory.labelRes) - } else { - stringResource(page.titleRes) - }, - showBack = previousPage != null, - onBack = { previousPage?.let(onPageChange) }, - ) - } - when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = true, - onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, - onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, - onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, - onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = { openInlinePage(SettingsPage.Account) }, - onSwitchProfileClick = onSwitchProfile, - showAccountSection = activeCategory == SettingsCategory.Account, - showGeneralSection = activeCategory == SettingsCategory.General, - showAboutSection = activeCategory == SettingsCategory.About, - ) - SettingsPage.Account -> accountSettingsContent( - isTablet = true, - ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = true, - ) - SettingsPage.Playback -> playbackSettingsContent( - isTablet = true, - showLoadingOverlay = showLoadingOverlay, - holdToSpeedEnabled = holdToSpeedEnabled, - holdToSpeedValue = holdToSpeedValue, - preferredAudioLanguage = preferredAudioLanguage, - secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, - streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, - decoderPriority = decoderPriority, - mapDV7ToHevc = mapDV7ToHevc, - tunnelingEnabled = tunnelingEnabled, - useLibass = useLibass, - libassRenderType = libassRenderType, - ) - SettingsPage.Appearance -> appearanceSettingsContent( - isTablet = true, - selectedTheme = selectedTheme, - onThemeSelected = onThemeSelected, - amoledEnabled = amoledEnabled, - onAmoledToggle = onAmoledToggle, - selectedAppLanguage = selectedAppLanguage, - onAppLanguageSelected = onAppLanguageSelected, - onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, - onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, - ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = true, - uiState = episodeReleaseNotificationsUiState, - ) - SettingsPage.ContinueWatching -> continueWatchingSettingsContent( - isTablet = true, - isVisible = continueWatchingPreferencesUiState.isVisible, - style = continueWatchingPreferencesUiState.style, - upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, - showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, - ) - SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( - isTablet = true, - uiState = posterCardStyleUiState, - ) - SettingsPage.ContentDiscovery -> contentDiscoveryContent( - isTablet = true, - showPluginsEntry = AppFeaturePolicy.pluginsEnabled, - onAddonsClick = { openInlinePage(SettingsPage.Addons) }, - onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, - onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, - onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, - onCollectionsClick = onCollectionsClick, - ) - SettingsPage.Addons -> addonsSettingsContent() - SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() - SettingsPage.Homescreen -> homescreenSettingsContent( - isTablet = true, - heroEnabled = homescreenHeroEnabled, - items = homescreenItems, - ) - SettingsPage.MetaScreen -> metaScreenSettingsContent( - isTablet = true, - uiState = metaScreenSettingsUiState, - ) - SettingsPage.Integrations -> integrationsContent( - isTablet = true, - onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, - onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, - ) - SettingsPage.TmdbEnrichment -> tmdbSettingsContent( - isTablet = true, - settings = tmdbSettings, - ) - SettingsPage.MdbListRatings -> mdbListSettingsContent( - isTablet = true, - settings = mdbListSettings, - ) - SettingsPage.TraktAuthentication -> traktSettingsContent( - isTablet = true, - uiState = traktAuthUiState, - commentsEnabled = traktCommentsEnabled, - onCommentsEnabledChange = TraktCommentsSettings::setEnabled, - ) + saveableStateHolder.SaveableStateProvider(page.name) { + val listState = rememberLazyListState() + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 40.dp, + top = topOffset, + end = 40.dp, + bottom = 40.dp, + ), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + val previousPage = page.previousPage() + TabletPageHeader( + title = if (page == SettingsPage.Root) { + stringResource(activeCategory.labelRes) + } else { + stringResource(page.titleRes) + }, + showBack = previousPage != null, + onBack = { previousPage?.let(onPageChange) }, + ) + } + when (page) { + SettingsPage.Root -> settingsRootContent( + isTablet = true, + onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, + onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, + onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, + onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, + onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = { openInlinePage(SettingsPage.Account) }, + onSwitchProfileClick = onSwitchProfile, + showAccountSection = activeCategory == SettingsCategory.Account, + showGeneralSection = activeCategory == SettingsCategory.General, + showAboutSection = activeCategory == SettingsCategory.About, + ) + SettingsPage.Account -> accountSettingsContent( + isTablet = true, + ) + SettingsPage.SupportersContributors -> supportersContributorsContent( + isTablet = true, + ) + SettingsPage.Playback -> playbackSettingsContent( + isTablet = true, + showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, + streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, + streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + decoderPriority = decoderPriority, + mapDV7ToHevc = mapDV7ToHevc, + tunnelingEnabled = tunnelingEnabled, + useLibass = useLibass, + libassRenderType = libassRenderType, + ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = true, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, + onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, + onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, + ) + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = true, + uiState = episodeReleaseNotificationsUiState, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = true, + isVisible = continueWatchingPreferencesUiState.isVisible, + style = continueWatchingPreferencesUiState.style, + upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = true, + uiState = posterCardStyleUiState, + ) + SettingsPage.ContentDiscovery -> contentDiscoveryContent( + isTablet = true, + showPluginsEntry = AppFeaturePolicy.pluginsEnabled, + onAddonsClick = { openInlinePage(SettingsPage.Addons) }, + onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, + onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, + onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, + onCollectionsClick = onCollectionsClick, + ) + SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() + SettingsPage.Homescreen -> homescreenSettingsContent( + isTablet = true, + heroEnabled = homescreenHeroEnabled, + items = homescreenItems, + ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = true, + uiState = metaScreenSettingsUiState, + ) + SettingsPage.Integrations -> integrationsContent( + isTablet = true, + onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + ) + SettingsPage.TmdbEnrichment -> tmdbSettingsContent( + isTablet = true, + settings = tmdbSettings, + ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = true, + settings = mdbListSettings, + ) + SettingsPage.TraktAuthentication -> traktSettingsContent( + isTablet = true, + uiState = traktAuthUiState, + commentsEnabled = traktCommentsEnabled, + onCommentsEnabledChange = TraktCommentsSettings::setEnabled, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt new file mode 100644 index 00000000..0620035f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_hide_secret +import nuvio.composeapp.generated.resources.settings_show_secret +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SettingsSecretTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + isError: Boolean = false, +) { + var visible by rememberSaveable { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + isError = isError, + singleLine = true, + label = { Text(label) }, + visualTransformation = if (visible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = if (visible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + contentDescription = stringResource( + if (visible) Res.string.settings_hide_secret else Res.string.settings_show_secret, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index 8e1c330f..ed2fbe31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -265,21 +265,13 @@ private fun TmdbApiKeyRow( val normalizedDraft = draft.trim() - OutlinedTextField( + SettingsSecretTextField( value = draft, onValueChange = { draft = it }, modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringResource(Res.string.settings_tmdb_api_key_label)) }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), + label = stringResource(Res.string.settings_tmdb_api_key_label), ) Row(modifier = Modifier.fillMaxWidth()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 545f71fd..c7db8b2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -25,10 +25,18 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val isTorrentStream: Boolean + get() = !infoHash.isNullOrBlank() || + url.isMagnetLink() || + externalUrl.isMagnetLink() + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null } +private fun String?.isMagnetLink(): Boolean = + this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true + data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index fdf38d55..a0cadbc0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -129,6 +129,7 @@ fun StreamsScreen( val clipboardManager = LocalClipboardManager.current val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) + val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -205,7 +206,13 @@ fun StreamsScreen( uiState = uiState, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, - onStreamSelected = onStreamSelected, + onStreamSelected = { stream, positionMs, progressFraction -> + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + } else { + onStreamSelected(stream, positionMs, progressFraction) + } + }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } else { @@ -220,7 +227,13 @@ fun StreamsScreen( uiState = uiState, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, - onStreamSelected = onStreamSelected, + onStreamSelected = { stream, positionMs, progressFraction -> + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + } else { + onStreamSelected(stream, positionMs, progressFraction) + } + }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } @@ -830,7 +843,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, onClick = { - if (stream.directPlaybackUrl != null) { + if (stream.directPlaybackUrl != null || stream.isTorrentStream) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, @@ -936,7 +949,7 @@ private fun StreamCard( onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null + val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 27c6fcd1..0612a6b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -1,8 +1,7 @@ package com.nuvio.app.features.watching.domain -private const val InProgressStartThresholdFraction = 0.02f -private const val CompletionThresholdFraction = 0.85 -private const val InProgressStartThresholdMinMs = 30_000L +private const val CompletionThresholdFraction = 0.90 +private const val ProgressStoreThresholdMs = 1_000L private const val UpcomingNextSeasonWindowDays = 7 fun watchedKey( @@ -14,17 +13,7 @@ fun watchedKey( fun shouldStoreProgress( positionMs: Long, durationMs: Long, -): Boolean { - val thresholdMs = if (durationMs > 0L) { - maxOf( - InProgressStartThresholdMinMs, - (durationMs * InProgressStartThresholdFraction).toLong(), - ) - } else { - 1L - } - return positionMs >= thresholdMs -} +): Boolean = positionMs >= ProgressStoreThresholdMs fun isProgressComplete( positionMs: Long, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index d07261fd..658bdb66 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -26,17 +26,17 @@ class WatchProgressRulesTest { } @Test - fun `save threshold uses max of thirty seconds and two percent`() { - assertFalse(shouldStoreWatchProgress(positionMs = 29_999L, durationMs = 600_000L)) - assertTrue(shouldStoreWatchProgress(positionMs = 30_000L, durationMs = 600_000L)) - assertFalse(shouldStoreWatchProgress(positionMs = 119_999L, durationMs = 6_000_000L)) - assertTrue(shouldStoreWatchProgress(positionMs = 120_000L, durationMs = 6_000_000L)) + fun `save threshold starts after one second`() { + assertFalse(shouldStoreWatchProgress(positionMs = 999L, durationMs = 600_000L)) + assertTrue(shouldStoreWatchProgress(positionMs = 1_000L, durationMs = 600_000L)) + assertTrue(shouldStoreWatchProgress(positionMs = 1_000L, durationMs = 0L)) } @Test fun `completion detects watched threshold remaining time and ended state`() { assertTrue(isWatchProgressComplete(positionMs = 920_000L, durationMs = 1_000_000L, isEnded = false)) - assertTrue(isWatchProgressComplete(positionMs = 850_000L, durationMs = 1_000_000L, isEnded = false)) + assertTrue(isWatchProgressComplete(positionMs = 900_000L, durationMs = 1_000_000L, isEnded = false)) + assertFalse(isWatchProgressComplete(positionMs = 899_999L, durationMs = 1_000_000L, isEnded = false)) assertTrue(isWatchProgressComplete(positionMs = 1L, durationMs = 0L, isEnded = true)) assertFalse(isWatchProgressComplete(positionMs = 200_000L, durationMs = 1_000_000L, isEnded = false)) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index a0f97372..90575e4d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.unit.IntSize import platform.Foundation.NSNotificationCenter @@ -32,10 +33,12 @@ actual fun LockPlayerToLandscape() { } @Composable -actual fun EnterImmersivePlayerMode() { +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { + SideEffect { + UIApplication.sharedApplication.setIdleTimerDisabled(keepScreenAwake) + } + DisposableEffect(Unit) { - // Request idle timer disabled to keep screen awake during playback - UIApplication.sharedApplication.setIdleTimerDisabled(true) onDispose { UIApplication.sharedApplication.setIdleTimerDisabled(false) } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 9839d1f0..06779ac2 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -579,15 +579,29 @@ final class MPVPlayerViewController: UIViewController { for i in 0.. String { + (getString("track-list/\(index)/\(field)") ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func formatTrackTitle( + type: String, + index: Int, + title: String, + lang: String, + codec: String, + decoderDescription: String, + channels: String, + channelCount: Int + ) -> String { + let base = ifNotBlank(title) + ?? localizedLanguageName(lang) + ?? (type == "sub" ? "Subtitle \(index + 1)" : "Track \(index + 1)") + let codecName = codecDisplayName(codec) ?? codecDisplayName(decoderDescription) + let channelName = type == "audio" ? channelLayoutName(channels: channels, channelCount: channelCount) : nil + let details = [channelName, codecName] + .compactMap { $0 } + .filter { detail in !base.localizedCaseInsensitiveContains(detail) } + return details.isEmpty ? base : "\(base) (\(details.joined(separator: ", ")))" + } + + private func ifNotBlank(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func localizedLanguageName(_ languageCode: String) -> String? { + guard let code = ifNotBlank(languageCode) else { return nil } + return Locale.current.localizedString(forLanguageCode: code) ?? code + } + + private func channelLayoutName(channels: String, channelCount: Int) -> String? { + if let normalized = ifNotBlank(channels), normalized != "unknown" { + let lower = normalized.lowercased() + if lower == "mono" { return "Mono" } + if lower == "stereo" { return "Stereo" } + return normalized + } + switch channelCount { + case 1: + return "Mono" + case 2: + return "Stereo" + case 6: + return "5.1" + case 8: + return "7.1" + case let count where count > 0: + return "\(count)ch" + default: + return nil + } + } + + private func codecDisplayName(_ value: String) -> String? { + guard let raw = ifNotBlank(value) else { return nil } + let codec = raw.lowercased() + if codec.contains("eac3") || codec.contains("e-ac-3") || codec.contains("e ac-3") { + return codec.contains("joc") || codec.contains("atmos") ? "E-AC-3-JOC" : "E-AC-3" + } + if codec.contains("truehd") || codec.contains("true hd") { return "TrueHD" } + if codec.contains("ac3") || codec.contains("ac-3") { return "AC-3" } + if codec.contains("dts-hd") || codec.contains("dtshd") || codec.contains("dts hd") { return "DTS-HD" } + if codec.contains("dts") || codec == "dca" { return "DTS" } + if codec.contains("aac") { return "AAC" } + if codec.contains("mp3") || codec.contains("mpeg audio") { return "MP3" } + if codec.contains("mp2") { return "MP2" } + if codec.contains("opus") { return "Opus" } + if codec.contains("vorbis") { return "Vorbis" } + if codec.contains("flac") { return "FLAC" } + if codec.contains("alac") { return "ALAC" } + if codec.contains("pcm") || codec.contains("wav") { return "WAV" } + if codec.contains("amr_wb") || codec.contains("amr-wb") { return "AMR-WB" } + if codec.contains("amr_nb") || codec.contains("amr-nb") { return "AMR-NB" } + if codec.contains("amr") { return "AMR" } + if codec.contains("iamf") { return "IAMF" } + if codec.contains("mpegh") || codec.contains("mpeg-h") { return "MPEG-H" } + if codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } + if codec.contains("subrip") || codec == "srt" { return "SRT" } + if codec.contains("ass") || codec.contains("ssa") { return "SSA" } + if codec.contains("webvtt") || codec == "vtt" { return "VTT" } + if codec.contains("ttml") { return "TTML" } + if codec.contains("mov_text") || codec.contains("tx3g") { return "TX3G" } + if codec.contains("dvb") { return "DVB" } + return raw + } + private func clearPlaybackError() { errorStateLock.lock() recentPlaybackLogs.removeAll(keepingCapacity: true)