diff --git a/composeApp/src/androidFull/AndroidManifest.xml b/composeApp/src/androidFull/AndroidManifest.xml
index 16854174..47497cd7 100644
--- a/composeApp/src/androidFull/AndroidManifest.xml
+++ b/composeApp/src/androidFull/AndroidManifest.xml
@@ -3,16 +3,4 @@
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index dc8f0964..f77af143 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -40,6 +40,16 @@
+
+
+
+
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 339340ab..1b2734cd 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -25,6 +25,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
+import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage
@@ -65,6 +66,7 @@ class MainActivity : AppCompatActivity() {
MetaScreenSettingsStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
+ ExternalPlayerPlatform.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
new file mode 100644
index 00000000..3f101118
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt
@@ -0,0 +1,93 @@
+package com.nuvio.app.features.player
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.core.content.FileProvider
+import java.io.File
+import java.net.URI
+
+private const val AndroidSystemPlayerId = "android_system"
+
+internal actual object ExternalPlayerPlatform {
+ private var appContext: Context? = null
+
+ fun initialize(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ actual fun defaultPlayerId(): String? = AndroidSystemPlayerId
+
+ actual fun availablePlayers(): List =
+ listOf(ExternalPlayerApp(AndroidSystemPlayerId, "Android system player"))
+
+ actual fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult {
+ val context = appContext ?: return ExternalPlayerOpenResult.Failed
+ val uri = request.sourceUrl.toExternalPlaybackUri(context)
+ ?: return ExternalPlayerOpenResult.Failed
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, request.sourceUrl.videoMimeType())
+ addCategory(Intent.CATEGORY_DEFAULT)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (uri.scheme.equals("content", ignoreCase = true)) {
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ putExtra(Intent.EXTRA_TITLE, request.streamTitle ?: request.title)
+ putExtra("title", request.streamTitle ?: request.title)
+ if (request.sourceHeaders.isNotEmpty()) {
+ putExtra("headers", request.sourceHeaders.toAndroidHeadersBundle())
+ }
+ }
+
+ return try {
+ context.startActivity(intent)
+ ExternalPlayerOpenResult.Opened
+ } catch (_: ActivityNotFoundException) {
+ ExternalPlayerOpenResult.NoPlayerAvailable
+ } catch (_: Throwable) {
+ ExternalPlayerOpenResult.Failed
+ }
+ }
+
+ private fun String.toExternalPlaybackUri(context: Context): Uri? {
+ val trimmed = trim()
+ if (trimmed.isBlank()) return null
+ if (!trimmed.startsWith("file:", ignoreCase = true)) {
+ return Uri.parse(trimmed)
+ }
+
+ val localFile = runCatching { File(URI(trimmed)) }.getOrNull() ?: return Uri.parse(trimmed)
+ return runCatching {
+ FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ localFile,
+ )
+ }.getOrNull()
+ }
+}
+
+private fun Map.toAndroidHeadersBundle(): Bundle =
+ Bundle().apply {
+ forEach { (key, value) ->
+ putString(key, value)
+ }
+ }
+
+private fun String.videoMimeType(): String {
+ val normalized = substringBefore('?').substringBefore('#').lowercase()
+ return when {
+ normalized.endsWith(".m3u8") -> "application/x-mpegURL"
+ normalized.endsWith(".mpd") -> "application/dash+xml"
+ normalized.endsWith(".mkv") -> "video/x-matroska"
+ normalized.endsWith(".webm") -> "video/webm"
+ normalized.endsWith(".avi") -> "video/x-msvideo"
+ normalized.endsWith(".mov") -> "video/quicktime"
+ else -> "video/*"
+ }
+}
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
index 4a589306..5cb861a8 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt
@@ -23,6 +23,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value"
+ private const val externalPlayerEnabledKey = "external_player_enabled"
+ private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@@ -59,6 +61,8 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
+ externalPlayerEnabledKey,
+ externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey,
@@ -157,6 +161,40 @@ actual object PlayerSettingsStorage {
?.apply()
}
+ actual fun loadExternalPlayerEnabled(): Boolean? =
+ preferences?.let { sharedPreferences ->
+ val key = ProfileScopedKey.of(externalPlayerEnabledKey)
+ if (sharedPreferences.contains(key)) {
+ sharedPreferences.getBoolean(key, false)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerEnabled(enabled: Boolean) {
+ preferences
+ ?.edit()
+ ?.putBoolean(ProfileScopedKey.of(externalPlayerEnabledKey), enabled)
+ ?.apply()
+ }
+
+ actual fun loadExternalPlayerId(): String? =
+ preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null)
+
+ actual fun saveExternalPlayerId(playerId: String?) {
+ preferences
+ ?.edit()
+ ?.apply {
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ if (playerId.isNullOrBlank()) {
+ remove(key)
+ } else {
+ putString(key, playerId)
+ }
+ }
+ ?.apply()
+ }
+
actual fun loadPreferredAudioLanguage(): String? =
preferences?.getString(ProfileScopedKey.of(preferredAudioLanguageKey), null)
@@ -619,6 +657,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
+ loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
+ loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@@ -659,6 +699,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
+ payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
+ payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml
index 2badd023..180d60c4 100644
--- a/composeApp/src/androidMain/res/xml/locale_config.xml
+++ b/composeApp/src/androidMain/res/xml/locale_config.xml
@@ -1,13 +1,14 @@
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
new file mode 100644
index 00000000..9759b279
--- /dev/null
+++ b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 15301839..15b373b8 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -720,7 +720,7 @@
Pourcentage
Pourcentage de seuil
Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage.
- %1$s%
+ %1$s %
Instantané
%1$ss
Illimité
@@ -1017,7 +1017,7 @@
Aucun lien direct du stream disponible
Aucune métadonnée disponible
Actualiser les streams
- Reprendre depuis %1$d%
+ Reprendre depuis %1$d %
Reprendre depuis %1$s
TAILLE %1$s
Fermer la bande-annonce
@@ -1027,7 +1027,7 @@
%1$s • %2$s
Échec de la vérification des mises à jour
Échec du téléchargement
- Téléchargement %1$d%%
+ Téléchargement %1$d %
Impossible de démarrer l'installation
Vous utilisez la version la plus récente.
Activez l'installation d'applications pour Nuvio puis revenez pour continuer.
diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml
new file mode 100644
index 00000000..6f21bf11
--- /dev/null
+++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml
@@ -0,0 +1,1277 @@
+
+ Sumber data, pengakuan, dan lisensi platform
+ Apresiasi terbuka dan kredit proyek
+ Kembali
+ Batal
+ Tutup
+ Hapus
+ Selesai
+ Edit
+ Impor
+ Berikutnya
+ OK
+ Putar
+ Sebelumnya
+ Hapus
+ Urutkan Ulang
+ Kembalikan ke Setelan Bawaan
+ Lanjutkan
+ Coba Lagi
+ Simpan
+ Menginstal
+ Addon
+ Aktif
+ %1$d katalog
+ Dapat Dikonfigurasi
+ Memperbarui
+ %1$d sumber
+ Tidak Tersedia
+ Konfigurasi addon
+ Hapus addon
+ Tambahkan URL manifes untuk mulai memuat katalog, metadata, streaming, atau subtitle ke Nuvio.
+ Belum ada addon yang terpasang.
+ Masukkan URL addon.
+ URL Addon
+ Pasang Addon
+ Memuat detail manifes...
+ Memvalidasi URL manifes dan memuat detail addon sebelum dipasang.
+ Memeriksa Addon
+ Pemasangan Gagal
+ %1$s berhasil divalidasi dan ditambahkan.
+ Addon Terpasang
+ Turunkan addon
+ Naikkan addon
+ Aktif
+ Addon
+ Katalog
+ Perbarui addon
+ Tambah Addon
+ Addon Terpasang
+ Ikhtisar
+ %1$d aturan id
+ Versi %1$s
+ Dipilih
+ Salin JSON
+ %1$d koleksi, %2$d folder
+ Hapus \"%1$s\"? Tindakan ini tidak dapat dibatalkan.
+ Hapus Koleksi
+ Tambah Katalog
+ Tambah Folder
+ Semua genre
+ Tambahkan katalog dari addon yang terpasang untuk menentukan apa yang ditampilkan folder ini.
+ Belum ada sumber katalog
+ Pilih
+ Emoji
+ URL Gambar
+ Tidak Ada
+ Sampul
+ Buat Koleksi
+ Selesai
+ Edit Koleksi
+ Edit Folder
+ Atur identitas, tampilan, dan sumber katalog folder dengan struktur yang sama seperti editor koleksi utama.
+ Tambahkan satu untuk memulai.
+ Belum ada folder
+ Folder
+ Filter Genre
+ Hanya tampilkan gambar sampul
+ Sembunyikan Judul
+ Folder Baru
+ Tampilkan koleksi ini di atas semua katalog beranda biasa. Beberapa koleksi yang disematkan mengikuti urutan pembuatan koleksi.
+ Sematkan di Atas Katalog
+ URL gambar latar (opsional)
+ Nama folder
+ URL GIF animasi (diputar hanya saat difokuskan)
+ Nama koleksi
+ Simpan Perubahan
+ Simpan
+ Tampilan
+ Dasar
+ Sumber Katalog
+ Pilih katalog addon yang akan diagregasi oleh folder ini.
+ Pilih Katalog
+ Pilih genre
+ %1$d dipilih
+ %1$d katalog
+ %1$d dipilih
+ Poster
+ Kotak
+ Lebar
+ Gabungkan semua katalog ke dalam satu tab
+ Tampilkan Tab \"Semua\"
+ Putar GIF yang dikonfigurasi sebagai pengganti sampul statis jika tersedia.
+ Tampilkan GIF Jika Dikonfigurasi
+ %1$d sumber · %2$s
+ Bentuk Tile
+ Baris
+ Tab
+ Mode Tampilan
+ Sumber TMDB
+ Daftar Publik
+ Produksi
+ Jaringan
+ Koleksi
+ Orang
+ Sutradara
+ Kustom
+ Pilih sumber yang sudah jadi. Anda dapat mengedit atau menghapusnya setelah ditambahkan.
+ Tempelkan URL daftar TMDB publik atau hanya angka dari URL tersebut.
+ Cari berdasarkan nama studio, atau tempelkan ID/URL perusahaan TMDB dan tambahkan langsung.
+ Masukkan ID jaringan. Jaringan umum tersedia di Preset dan filter cepat.
+ Cari nama koleksi film atau tempelkan ID koleksi dari TMDB.
+ Masukkan ID atau URL orang TMDB untuk membuat baris dari kredit pemeran.
+ Masukkan ID atau URL orang TMDB untuk membuat baris dari kredit sutradara.
+ Buat baris TMDB langsung menggunakan filter opsional. Kosongkan kolom yang tidak diperlukan.
+ Daftar TMDB publik
+ ID Jaringan
+ ID Koleksi
+ ID Orang
+ Nama, ID, atau URL perusahaan produksi
+ ID atau URL TMDB
+ https://www.themoviedb.org/list/8504994 atau 8504994
+ 213 untuk Netflix, 49 untuk HBO, 2739 untuk Disney+
+ 10 untuk Koleksi Star Wars
+ Marvel Studios, 420, atau URL perusahaan
+ 31 untuk Tom Hanks, atau URL orang
+ Contoh: Marvel Studios, 420, atau https://www.themoviedb.org/company/420.
+ Contoh: Star Wars Collection, Harry Potter Collection, atau URL koleksi.
+ Contoh ID: Netflix 213, HBO 49, Disney+ 2739.
+ Contoh: https://www.themoviedb.org/list/8504994 atau 8504994.
+ Contoh: https://www.themoviedb.org/person/31-tom-hanks atau 31.
+ Judul tampilan
+ Ditampilkan sebagai nama baris/tab. Jika kosong, Nuvio akan membuatnya dari sumber.
+ Film Marvel, Netflix Originals, Pixar
+ Film Tom Hanks, Aktor Favorit
+ Film Christopher Nolan, Sutradara Favorit
+ Film Aksi Terbaik, Drama Korea, Animasi 2024
+ Hasil Pencarian
+ Koleksi TMDB
+ Perusahaan TMDB %1$d
+ Koleksi TMDB %1$d
+ Tipe
+ Film
+ Serial
+ Keduanya
+ Urutkan
+ Filter
+ Kosongkan kolom yang tidak diperlukan.
+ Genre cepat
+ Bahasa cepat
+ Negara cepat
+ Kata kunci cepat
+ Studio cepat
+ Jaringan cepat
+ ID Genre
+ Gunakan nomor genre TMDB. Pisahkan beberapa dengan koma untuk AND, atau garis vertikal untuk OR.
+ Tanggal rilis atau tayang dari
+ Tanggal rilis atau tayang sampai
+ Gunakan format YYYY-MM-DD, misalnya 2024-01-01.
+ Rating minimum
+ Rating maksimum
+ Rating TMDB dari 0 hingga 10. Contoh: 7.0.
+ Suara minimum
+ Gunakan ini untuk menghindari judul dengan suara rendah. Contoh: 100.
+ Bahasa asli
+ Gunakan kode bahasa dua huruf, misalnya en, ko, ja, hi.
+ Negara asal
+ Gunakan kode negara dua huruf, misalnya US, KR, JP, IN.
+ ID Kata Kunci
+ Gunakan nomor kata kunci TMDB. Chip cepat mengisi contoh umum.
+ 9715 untuk superhero
+ ID Perusahaan
+ Gunakan ID studio/perusahaan. Chip cepat mengisi contoh umum.
+ 420 untuk Marvel Studios
+ ID Jaringan
+ Hanya untuk serial. Gunakan ID jaringan seperti Netflix 213 atau HBO 49.
+ 213 untuk Netflix
+ Tahun
+ Gunakan tahun empat digit, misalnya 2024.
+ Preset
+ Cari
+ Tambah Sumber
+ Tambah Daftar Trakt
+ Edit Daftar Trakt
+ Daftar Trakt
+ Daftar Trakt
+ Cari judul, URL Trakt, atau ID daftar
+ Gunakan URL daftar Trakt publik atau ID daftar numerik, atau cari berdasarkan nama.
+ Tontonan Akhir Pekan, Pemenang Penghargaan
+ Hasil Pencarian
+ Daftar Tren
+ Daftar Populer
+ Arah
+ Naik
+ Turun
+ Urutan Daftar
+ Baru Ditambahkan
+ Judul
+ Dirilis
+ Durasi
+ Populer
+ Persentase
+ Suara
+ Aksi
+ Petualangan
+ Animasi
+ Komedi
+ Horor
+ Fiksi Ilmiah
+ Drama
+ Kejahatan
+ Reality
+ Inggris
+ Korea
+ Jepang
+ Hindi
+ Spanyol
+ Amerika Serikat
+ Korea
+ Jepang
+ India
+ Inggris Raya
+ Superhero
+ Berdasarkan Novel
+ Perjalanan Waktu
+ Luar Angkasa
+ Marvel
+ Disney
+ Pixar
+ Lucasfilm
+ Warner Bros.
+ Netflix
+ HBO
+ Disney+
+ Prime Video
+ Hulu
+ Asli
+ Populer
+ Nilai Tertinggi
+ Terbaru
+ Daftar TMDB
+ Koleksi Film TMDB
+ Produksi
+ Jaringan
+ Orang
+ Sutradara
+ Temukan TMDB
+ Buat satu untuk mengorganisir katalog Anda.
+ Belum ada koleksi
+ %1$d folder
+ Tidak ada item ditemukan
+ Folder tidak ditemukan
+ Koleksi
+ Impor Koleksi
+ JSON
+ Tempelkan JSON koleksi Anda di bawah ini.
+ Impor
+ Koleksi Baru
+ Disematkan
+ Semua
+ Koleksi Anda
+ Dibuat dengan ❤️ oleh Tapframe dan teman-teman
+ Versi %1$s (%2$s)
+ Nonaktif
+ Aktif
+ Jeda
+ Muat Ulang
+ Sudah punya akun?
+ Lanjutkan Tanpa Akun
+ Buat Akun
+ Belum punya akun?
+ Email
+ atau
+ Kata Sandi
+ Masuk untuk mengakses perpustakaan dan progres Anda
+ Masuk
+ Daftar untuk menyinkronkan data Anda di semua perangkat
+ Daftar
+ Data Anda hanya akan disimpan secara lokal
+ Streaming semua, di mana saja
+ Selamat Datang Kembali
+ Perpustakaan
+ Perpustakaan Trakt
+ Beranda
+ Perpustakaan
+ Profil
+ Pencarian
+ Trek Audio
+ Audio
+ Bawaan
+ Offset Bawah
+ Tutup pemutar
+ Warna
+ Sedang diputar
+ E%1$d
+ S%1$dE%2$d
+ S%1$dE%2$d • %3$s
+ Episode
+ Ukuran Font
+ %1$dsp
+ Kunci kontrol pemutar
+ Tidak ada trek audio tersedia
+ Tidak ada episode tersedia
+ Tidak ada stream ditemukan
+ Tidak Ada
+ Garis Tepi
+ Episode
+ Sumber
+ Stream
+ Kesalahan pemutaran
+ Memutar
+ Ketuk untuk mengambil subtitle
+ Kembali
+ Kembalikan ke Setelan Bawaan
+ Penuh
+ Sesuaikan
+ Perbesar
+ Mundur 10 detik
+ -%1$ds
+ +%1$ds
+ -%1$ds
+ +%1$ds
+ Maju 10 detik
+ Sumber
+ Gaya
+ Sub
+ Subtitle
+ Kecerahan %1$s
+ Volume %1$s
+ Dibisukan
+ Diunduh
+ Tayang
+ TBA
+ Ketuk untuk membuka kunci
+ Trek %1$d
+ Buka kunci kontrol pemutar
+ Sedang menonton
+ Tambah Profil
+ Hapus pencarian
+ Temukan
+ Addon yang terpasang gagal mengembalikan hasil pencarian yang valid.
+ Pencarian gagal
+ Pasang dan validasi setidaknya satu addon sebelum mencari.
+ Tidak ada addon aktif
+ Katalog yang dapat dicari tidak mengembalikan hasil untuk kueri ini.
+ Tidak ada hasil ditemukan
+ Addon yang terpasang tidak menyediakan pencarian katalog.
+ Tidak ada katalog yang dapat dicari
+ Cari film, acara...
+ Pencarian Terbaru
+ Hapus pencarian terbaru
+ Tentang
+ Umum
+ Akun
+ Addon
+ Tata Letak
+ Konten & Penemuan
+ Lanjutkan Menonton
+ Tata Letak Beranda
+ Integrasi
+ Lisensi & Atribusi
+ Rating MDBList
+ Halaman Detail
+ Notifikasi
+ Pemutaran
+ Plugin
+ Gaya Kartu Poster
+ Pengaturan
+ Pendukung & Kontributor
+ Pengayaan TMDB
+ Trakt
+ TENTANG
+ Status akun dan sinkronisasi
+ AKUN
+ Struktur beranda dan gaya poster
+ Unduh versi terbaru
+ Periksa pembaruan
+ Kelola addon dan sumber penemuan.
+ Kelola film dan episode yang telah diunduh.
+ Unduhan
+ UMUM
+ Kelola integrasi yang tersedia
+ Kelola peringatan rilis episode dan kirim notifikasi uji coba.
+ Beralih ke profil yang berbeda.
+ Ganti Profil
+ Buka layar koneksi Trakt
+ Tidak ada pengaturan ditemukan.
+ Cari pengaturan...
+ HASIL
+ LISENSI APLIKASI
+ DATA & LAYANAN
+ LISENSI PEMUTARAN
+ Nuvio Mobile
+ Kode sumber dan ketentuan lisensi tersedia di repositori proyek.
+ Dilisensikan di bawah GNU General Public License v3.0.
+ The Movie Database (TMDB)
+ Nuvio menggunakan API TMDB untuk metadata film dan TV, karya seni, trailer, pemeran, detail produksi, koleksi, dan rekomendasi. Produk ini menggunakan API TMDB tetapi tidak didukung atau disertifikasi oleh TMDB.
+ Dataset Non-Komersial IMDb
+ Nuvio menggunakan Dataset Non-Komersial IMDb, termasuk title.ratings.tsv.gz, untuk rating dan jumlah suara IMDb. Informasi disediakan oleh IMDb (https://www.imdb.com). Digunakan dengan izin. Data IMDb untuk penggunaan pribadi dan non-komersial sesuai ketentuan IMDb.
+ Trakt
+ Nuvio terhubung ke Trakt untuk autentikasi akun, riwayat tontonan, sinkronisasi progres, data perpustakaan, rating, daftar, dan komentar. Nuvio tidak berafiliasi atau didukung oleh Trakt.
+ MDBList
+ Nuvio menggunakan MDBList untuk rating dan data penyedia skor eksternal. Nuvio tidak berafiliasi atau didukung oleh MDBList.
+ IntroDB
+ Nuvio menggunakan API IntroDB untuk intro, rekap, kredit, dan pratinjau cap waktu yang disediakan komunitas yang digunakan oleh kontrol lewati. Nuvio tidak berafiliasi atau didukung oleh IntroDB.
+ MPVKit
+ Digunakan untuk pemutaran pada build iOS.
+ Sumber MPVKit saja dilisensikan di bawah LGPL v3.0. Bundle MPVKit, termasuk libmpv dan pustaka FFmpeg, juga dilisensikan di bawah LGPL v3.0.
+ AndroidX Media3 ExoPlayer 1.8.0
+ Digunakan untuk pemutaran pada build Android.
+ Dilisensikan di bawah Apache License, Versi 2.0.
+ Memuat daftar Trakt Anda…
+ Pilih tempat menyimpan judul ini di Trakt
+ Donasi
+ Lihat detail
+ Hapus
+ Mulai dari awal
+ Putar
+ %1$d/10
+ Ulasan
+ Spoiler
+ Belum ada ulasan Trakt tersedia.
+ %1$d suka
+ Komentar ini mengandung spoiler.
+ Komentar ini mengandung spoiler dan telah disembunyikan.
+ Komentar
+ Trailer
+ %1$s (%2$d)
+ Trailer
+ Tidak ada episode yang selesai diunduh
+ Belum ada unduhan
+ %1$d episode diunduh
+ Aktif
+ Film
+ Acara
+ Tampilkan Unduhan
+ Selesai • %1$s
+ Mengunduh • %1$s
+ Gagal
+ Dijeda • %1$s
+ Ditonton
+ Musim %1$d
+ Spesial
+ Lanjutkan dari tempat Anda berhenti
+ Tambah ke perpustakaan
+ Tandai sebagai belum ditonton
+ Tandai sebagai sudah ditonton
+ Hapus dari perpustakaan
+ Lihat Semua
+ Putar secara manual
+ Logo %1$s
+ Akun
+ Hapus Akun
+ Ini akan menghapus akun dan semua data terkait Anda secara permanen.
+ Tindakan ini tidak dapat dibatalkan. Semua data, profil, dan riwayat sinkronisasi Anda akan dihapus secara permanen.
+ Hapus Akun?
+ Email
+ Belum masuk
+ Keluar
+ Anda akan dikembalikan ke layar masuk.
+ Keluar?
+ Status
+ Anonim
+ Sudah masuk
+ Hitam AMOLED
+ Gunakan latar belakang hitam pekat untuk layar OLED.
+ Bahasa Aplikasi
+ Pilih Bahasa
+ Pengaturan untuk bagian Lanjutkan Menonton.
+ Liquid Glass
+ Gunakan tab bar iPhone asli di iOS 26 dan yang lebih baru. Pergantian profil instan dari tab bar tidak tersedia saat ini aktif.
+ Sesuaikan lebar kartu dan radius sudut.
+ TAMPILAN
+ BERANDA
+ TEMA
+ Koleksi • %1$s
+ Nama Tampilan
+ Pasang addon dengan katalog yang kompatibel untuk mengonfigurasi baris Beranda.
+ Tidak ada katalog beranda
+ Sumber hero
+ Tersembunyi
+ Pertahankan fokus Beranda
+ %1$s • Batas tercapai (maks %2$d)
+ Tidak ada sumber hero dipilih
+ Tidak di hero
+ Lepas sematkan ke atas dari koleksi untuk memindahkan
+ Disematkan
+ Disematkan ke atas
+ Urutkan Ulang
+ KATALOG
+ KATALOG & KOLEKSI
+ KOLEKSI
+ Tata Letak Beranda
+ Katalog Hero
+ %1$d dari %2$d dipilih
+ Tampilkan Bagian Hero
+ Tampilkan karousel hero di bagian atas beranda.
+ Sembunyikan Konten Belum Rilis
+ Sembunyikan film dan acara yang belum dirilis.
+ Sembunyikan Garis Bawah Katalog
+ Hapus garis aksen di bawah judul katalog dan koleksi di seluruh aplikasi.
+ %1$d dari %2$d katalog terlihat • %3$d sumber hero dipilih
+ Buka katalog hanya jika perlu mengganti nama atau mengurutkannya.
+ Terlihat
+ Sembunyikan nilai
+ Pemutar, subtitle, dan putar otomatis
+ Radius Sudut
+ Gaya Kartu Poster
+ Lebar
+ Kustom
+ Sesuaikan lebar kartu dan radius sudut.
+ Sembunyikan label
+ Poster Lanskap
+ Pratinjau Langsung
+ %1$s (%2$s)
+ Radius sudut: %1$ddp
+ Tinggi: %1$ddp
+ Lebar: %1$ddp
+ Klasik
+ Pil
+ Membulat
+ Tajam
+ Halus
+ Seimbang
+ Nyaman
+ Kompak
+ Padat
+ Besar
+ Standar
+ Tampilkan nilai
+ Tampilkan popup untuk melanjutkan dari tempat Anda berhenti saat membuka aplikasi setelah keluar dari pemutar.
+ Prompt lanjutkan saat dibuka
+ Buramkan thumbnail episode berikutnya di Lanjutkan Menonton untuk menghindari spoiler.
+ Buramkan yang Belum Ditonton di Lanjutkan Menonton
+ Sertakan episode mendatang di Lanjutkan Menonton sebelum tayang.
+ Tampilkan Episode Berikutnya yang Belum Tayang
+ URUTAN SORTIR
+ Urutan Sortir
+ Setelan Bawaan
+ Urutkan semua item berdasarkan yang terbaru
+ Gaya Streaming
+ Item yang sudah rilis lebih dulu, yang mendatang di akhir
+ Gaya Kartu Poster
+ SAAT DIBUKA
+ PERILAKU BERIKUTNYA
+ VISIBILITAS
+ Tampilkan rak Lanjutkan Menonton di Beranda.
+ Tampilkan Lanjutkan Menonton
+ Poster
+ Kartu poster yang mengutamakan karya seni
+ Lebar
+ Kartu horizontal padat informasi
+ Tampilkan episode berikutnya berdasarkan episode yang paling jauh ditonton. Nonaktifkan untuk menonton ulang agar menggunakan episode yang paling baru ditonton.
+ Berikutnya dari Episode Terjauh
+ Utamakan thumbnail episode jika tersedia.
+ Utamakan Thumbnail Episode di Lanjutkan Menonton
+ BERANDA
+ SUMBER
+ Pasang, hapus, perbarui, dan urutkan sumber konten Anda.
+ Pasang repositori scraper JavaScript dan uji penyedia secara internal.
+ Sesuaikan tata letak beranda, visibilitas konten, dan poster
+ Pengaturan untuk layar detail dan episode.
+ Buat pengelompokan katalog kustom dengan folder yang ditampilkan di Beranda.
+ Integrasi
+ Kontrol pengayaan metadata
+ Penyedia rating eksternal
+ Tambahkan kunci API MDBList Anda di bawah sebelum mengaktifkan rating.
+ Diperlukan untuk mengambil rating dari MDBList
+ Kunci API
+ Kunci API
+ Aktifkan Rating MDBList
+ Ambil rating dari penyedia eksternal di layar detail metadata
+ Kunci API
+ Penyedia rating eksternal
+ Rating MDBList
+ Tindakan
+ Kontrol putar dan simpan.
+ Pemeran
+ Daftar pemeran utama.
+ Latar Sinematik
+ Latar belakang buram di belakang konten, mirip dengan layar streaming.
+ Koleksi
+ Baris koleksi atau waralaba terkait.
+ Komentar
+ Ulasan dari Trakt
+ Detail
+ Durasi, status, rilis, bahasa, dan info terkait.
+ Kartu Episode
+ Pilih cara episode ditampilkan di layar metadata.
+ Horizontal
+ Kartu baris bergaya latar belakang
+ Daftar
+ Kartu bertumpuk yang mengutamakan detail
+ Episode
+ Musim dan daftar episode untuk serial.
+ Buramkan Episode Belum Ditonton
+ Buramkan thumbnail episode sampai ditonton untuk menghindari spoiler.
+ Grup %1$d
+ Lebih seperti ini
+ Latar rekomendasi TMDB di halaman detail
+ Tidak Ada
+ Ikhtisar
+ Sinopsis, rating, genre, dan kredit utama.
+ Produksi
+ Studio dan jaringan.
+ TAMPILAN
+ BAGIAN
+ Grup Tab %1$d
+ Tata Letak Tab
+ Kelompokkan bagian ke dalam tab seperti aplikasi TV. Tetapkan hingga 3 bagian per grup tab.
+ Trailer
+ Baris trailer dan pintasan pemutaran.
+ Notifikasi saat ini dinonaktifkan di Nuvio.
+ Peringatan rilis episode
+ Jadwalkan notifikasi lokal ketika episode baru untuk acara yang disimpan tersedia.
+ Notifikasi sistem dinonaktifkan untuk Nuvio. Aktifkan untuk menerima peringatan dan uji notifikasi.
+ %1$d peringatan rilis saat ini dijadwalkan di perangkat ini.
+ PERINGATAN
+ UJI COBA
+ Kirim Notifikasi Uji Coba
+ Mengirim Notifikasi Uji Coba...
+ Kirim notifikasi uji coba lokal untuk %1$s.
+ Simpan acara ke perpustakaan Anda terlebih dahulu untuk menguji notifikasi.
+ Notifikasi uji coba
+ Komunitas
+ Lihat orang-orang yang membangun dan mendukung Nuvio di Mobile, TV, dan Web.
+ API Pendukung tidak dikonfigurasi. Tambahkan DONATIONS_BASE_URL ke local.properties.
+ Kontributor
+ Pendukung
+ Buka GitHub
+ Profil GitHub tidak tersedia
+ Tidak ada pesan terlampir.
+ Memuat kontributor...
+ Memuat pendukung...
+ Gagal memuat kontributor
+ Gagal memuat pendukung
+ Tidak ada kontributor ditemukan.
+ Tidak ada pendukung ditemukan.
+ Tidak dapat memuat kontributor.
+ Tidak dapat memuat pendukung.
+ Tidak dapat memuat kontributor saat ini.
+ Tidak dapat memuat pendukung saat ini.
+ %1$d total commit
+ Jan
+ Feb
+ Mar
+ Apr
+ Mei
+ Jun
+ Jul
+ Agu
+ Sep
+ Okt
+ Nov
+ Des
+ %1$s %2$s, %3$s
+ Semua addon terpasang
+ Semua plugin yang diaktifkan
+ Addon yang Diizinkan
+ Plugin yang Diizinkan
+ Anime Skip
+ ID Klien AnimeSkip
+ Masukkan ID klien API AnimeSkip Anda. Dapatkan di anime-skip.com.
+ Aktifkan Pengiriman Intro
+ Tampilkan tombol untuk mengirim cap waktu intro/outro ke database komunitas.
+ Kunci API IntroDB
+ Masukkan kunci API IntroDB Anda untuk mengirim cap waktu. Diperlukan untuk pengiriman.
+ Juga cari AnimeSkip untuk cap waktu lewati (memerlukan ID klien).
+ Putar Otomatis Episode Berikutnya
+ Mulai episode berikutnya secara otomatis ketika prompt muncul.
+ Hanya dekoder perangkat
+ Utamakan dekoder aplikasi (FFmpeg)
+ Utamakan dekoder perangkat
+ Prioritas Dekoder
+ Ketuk di luar untuk menutup
+ Ketuk di luar untuk menyimpan & menutup
+ %1$d hari
+ %1$d hari
+ %1$d jam
+ %1$d jam
+ Gunakan libass untuk subtitle ASS/SSA
+ Eksperimental: rendering ASS/SSA lanjutan (gaya, posisi, animasi)
+ Kecepatan Putar saat Ditahan
+ Tahan untuk Mempercepat
+ Tekan lama di mana saja pada permukaan pemutar untuk sementara meningkatkan kecepatan putar.
+ Pola regex tidak valid
+ Durasi Cache Tautan Terakhir
+ DV7 - Fallback HEVC
+ Petakan Dolby Vision Profil 7 ke HEVC standar untuk perangkat tanpa dukungan hardware DV
+ Menit Ambang Batas
+ Cadangan ketika tidak ada cap waktu outro.
+ %1$s mnt
+ Tidak ada item tersedia
+ Belum diatur
+ Setelan Bawaan (file media)
+ Bahasa perangkat
+ Paksa
+ Tidak Ada
+ Utamakan Grup Binge (Episode Berikutnya)
+ Coba profil sumber yang sama terlebih dahulu (addon/grup kualitas yang sama) sebelum aturan putar otomatis normal.
+ Bahasa Audio yang Diutamakan
+ Bahasa yang Diutamakan
+ Preset
+ Mencocokkan dengan nama/judul/deskripsi/addon/url stream. Contoh: 4K|2160p|Remux
+ Pola Regex
+ Tidak ada pola yang diatur. Contoh: 4K|2160p|Remux
+ 1080p ke atas
+ AVC / x264
+ Kualitas BluRay
+ Dolby Atmos / DTS
+ Inggris
+ HDR / Dolby Vision
+ HEVC / x265
+ Tanpa CAM/TS
+ Tanpa REMUX/HDR
+ 1080p Standar
+ 4K / Remux
+ 720p / Lebih Kecil
+ Sumber WEB
+ Mode Render Libass
+ Cues Standar
+ Effects Canvas
+ Effects OpenGL
+ Overlay Canvas
+ Overlay OpenGL (Direkomendasikan)
+ Gunakan Ulang Tautan Terakhir
+ Putar otomatis stream terakhir yang berfungsi untuk film/episode yang sama ketika cache masih valid
+ Bahasa Audio Sekunder
+ Bahasa yang Diutamakan Sekunder
+ DEKODER
+ EPISODE BERIKUTNYA
+ PEMUTAR
+ LEWATI SEGMEN
+ PUTAR OTOMATIS STREAM
+ PEMILIHAN STREAM
+ SUBTITLE DAN AUDIO
+ RENDERING SUBTITLE
+ %1$d dipilih
+ Overlay Pemuatan
+ Tampilkan layar pemuatan sampai frame video pertama muncul.
+ Lewati Intro
+ Gunakan introdb.app untuk mendeteksi intro dan rekap.
+ Cakupan Sumber Putar Otomatis
+ Semua addon terpasang
+ Putar otomatis hanya mempertimbangkan stream dari addon yang terpasang.
+ Semua sumber
+ Putar otomatis dapat menggunakan addon terpasang maupun plugin yang diaktifkan.
+ Hanya plugin yang diaktifkan
+ Putar otomatis hanya mempertimbangkan stream dari plugin yang diaktifkan.
+ Hanya addon terpasang
+ Putar otomatis hanya mempertimbangkan stream dari addon yang terpasang.
+ Pemilihan Stream Otomatis
+ Putar otomatis sumber pertama
+ Putar sumber yang tersedia pertama secara otomatis.
+ Manual (pilih stream)
+ Selalu tampilkan daftar sumber dan biarkan saya memilih.
+ Putar otomatis cocokkan regex
+ Putar sumber pertama yang teksnya cocok dengan pola regex Anda.
+ Batas Waktu Pemilihan Stream
+ Waktu tunggu addon sebelum memilih.
+ Menit Ambang Batas
+ Mode Ambang Batas Episode Berikutnya
+ Menit sebelum selesai
+ Persentase
+ Persentase Ambang Batas
+ Cadangan ketika tidak ada cap waktu outro.
+ %1$s%
+ Instan
+ %1$sd
+ Tak Terbatas
+ Pemutaran Tunneled
+ Sinkronisasi audio/video tingkat hardware. Dapat meningkatkan pemutaran di beberapa perangkat Android TV
+ Tambahkan kunci API TMDB Anda sendiri di bawah sebelum mengaktifkan pengayaan.
+ Kunci API
+ Aktifkan Pengayaan TMDB
+ Gunakan TMDB sebagai sumber metadata untuk meningkatkan data addon
+ Masukkan kunci API v3 TMDB Anda.
+ Kode bahasa
+ Gambar
+ Gambar logo dan latar dari TMDB
+ Info Dasar
+ Deskripsi, genre, dan rating dari TMDB
+ Koleksi
+ Koleksi film TMDB dalam urutan rilis
+ Kredit
+ Pemeran dengan foto, sutradara, dan penulis dari TMDB
+ Detail
+ Durasi, status, negara, dan bahasa dari TMDB
+ Episode
+ Judul episode, ikhtisar, thumbnail, dan durasi dari TMDB
+ Yang Seperti Ini
+ Latar rekomendasi TMDB di halaman detail
+ Jaringan
+ Jaringan dengan logo dari TMDB
+ Produksi
+ Perusahaan produksi dari TMDB
+ Poster Musim
+ Gunakan poster musim TMDB di pemilih musim layar metadata untuk serial.
+ Trailer
+ Kandidat trailer dari video TMDB untuk bagian trailer detail
+ Kunci API pribadi
+ Bahasa
+ Bahasa metadata TMDB untuk judul, logo, dan kolom yang diaktifkan
+ KREDENSIAL
+ LOKALISASI
+ MODUL
+ Pengayaan TMDB
+ Setelah disetujui, Anda akan diarahkan kembali secara otomatis.
+ AUTENTIKASI
+ Komentar
+ Tampilkan ulasan Trakt di halaman metadata
+ Hubungkan Trakt
+ Terhubung sebagai %1$s
+ Pengguna Trakt
+ Putuskan Koneksi
+ Gagal membuka browser
+ FITUR
+ Selesaikan masuk Trakt di browser Anda
+ Sinkronkan daftar tonton, progres menonton, lanjutkan menonton, scrobble, dan daftar pribadi Anda dengan Trakt.
+ Kredensial Trakt hilang di local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).
+ Buka Login Trakt
+ Anda sekarang dapat menyimpan ke daftar tonton Trakt dan personal.
+ Masuk dengan Trakt untuk mengaktifkan penyimpanan berbasis daftar dan mode perpustakaan Trakt.
+ Sumber Perpustakaan
+ Pilih perpustakaan mana yang digunakan untuk menyimpan dan melihat koleksi Anda
+ Sumber Perpustakaan
+ Pilih tempat menyimpan dan mengelola item perpustakaan Anda
+ Trakt
+ Perpustakaan Nuvio
+ Perpustakaan Trakt dipilih
+ Perpustakaan Nuvio dipilih
+ Progres Menonton
+ Pilih sumber progres mana yang digunakan untuk melanjutkan menonton
+ Progres Menonton
+ Pilih apakah resume dan lanjutkan menonton harus menggunakan Trakt atau Nuvio Sync sementara scrobbling Trakt tetap aktif.
+ Trakt
+ Nuvio Sync
+ Sumber progres menonton diatur ke Trakt
+ Sumber progres menonton diatur ke Nuvio Sync
+ Jendela Lanjutkan Menonton
+ Lanjutkan menonton berdasarkan riwayat Trakt
+ Jendela Lanjutkan Menonton
+ Pilih berapa banyak aktivitas Trakt yang harus muncul di lanjutkan menonton.
+ Semua riwayat
+ %1$d hari
+ Skor Penonton
+ IMDb
+ Letterboxd
+ Metacritic
+ Rotten Tomatoes
+ TMDB
+ Trakt
+ Tidak Diketahui
+ Amber
+ Delima
+ Zamrud
+ Samudra
+ Mawar
+ Lavender
+ Melati
+ Episode Berikutnya
+ Mencari sumber…
+ Memutar melalui %1$s dalam %2$d…
+ Thumbnail episode berikutnya
+ Belum Tayang
+ Lewati
+ Lewati Intro
+ Lewati Outro
+ Lewati Rekap
+ Tidak ada subtitle ditemukan
+ Afrikaans
+ Albania
+ Amharik
+ Arab
+ Armenia
+ Azerbaijan
+ Basque
+ Belarusia
+ Bengali
+ Bosnia
+ Bulgaria
+ Burma
+ Katalan
+ Tiongkok
+ Tiongkok (Sederhana)
+ Tiongkok (Tradisional)
+ Kroasia
+ Ceko
+ Denmark
+ Belanda
+ Inggris
+ Estonia
+ Filipina
+ Finlandia
+ Prancis
+ Galisia
+ Georgia
+ Jerman
+ Yunani
+ Gujarati
+ Ibrani
+ Hindi
+ Hungaria
+ Islandia
+ Indonesia
+ Irlandia
+ Italia
+ Jepang
+ Kannada
+ Kazakh
+ Khmer
+ Korea
+ Lao
+ Latvia
+ Lituania
+ Makedonia
+ Melayu
+ Malayalam
+ Malta
+ Marathi
+ Mongolia
+ Nepali
+ Norwegia
+ Persia
+ Polandia
+ Portugis (Portugal)
+ Portugis (Brasil)
+ Punjabi
+ Rumania
+ Rusia
+ Serbia
+ Sinhala
+ Slovak
+ Slovenia
+ Spanyol
+ Spanyol (Amerika Latin)
+ Swahili
+ Swedia
+ Tamil
+ Telugu
+ Thai
+ Turki
+ Ukraina
+ Urdu
+ Uzbek
+ Vietnam
+ Welsh
+ Zulu
+ Hapus
+ Lanjutkan
+ Abaikan
+ Pasang
+ Nanti
+ Tidak
+ Perbarui
+ Ya
+ Apakah Anda ingin keluar dari aplikasi?
+ Keluar dari aplikasi
+ Katalog ini tidak mengembalikan item apa pun.
+ Tidak ada judul ditemukan
+ Periksa koneksi Wi-Fi atau data seluler Anda dan coba lagi.
+ Sutradara
+ Gagal memuat
+ Lebih Seperti Ini
+ Musim
+ Addon ini mengembalikan video untuk serial, tetapi tidak ada yang menyertakan nomor musim atau episode.
+ Addon ini tidak menyediakan metadata episode untuk serial ini.
+ Episode belum dipublikasikan oleh addon ini.
+ Perangkat Anda terhubung, tetapi Nuvio tidak dapat menjangkau server yang diperlukan.
+ Tampilkan Lebih Sedikit
+ Tampilkan Lebih Banyak ▾
+ Penulis
+ Semua Genre
+ Katalog
+ %1$s • %2$s
+ Katalog yang dipilih gagal mengembalikan item temuan.
+ Tidak dapat memuat temuan
+ Addon yang terpasang tidak mengekspos katalog yang kompatibel untuk temuan.
+ Tidak ada katalog temuan
+ Katalog dan filter yang dipilih tidak mengembalikan item apa pun.
+ Tidak ada judul ditemukan
+ Pasang dan validasi setidaknya satu addon sebelum menelusuri katalog temuan.
+ Pilih Katalog
+ Pilih Genre
+ Pilih Tipe
+ Tipe
+ Tandai sebelumnya sebagai belum ditonton
+ Tandai sebelumnya sebagai sudah ditonton
+ Tandai %1$s sebagai belum ditonton
+ Tandai %1$s sebagai sudah ditonton
+ Tandai sebagai belum ditonton
+ Tandai sebagai sudah ditonton
+ Berikutnya
+ %1$s ditonton
+ Pasang dan validasi setidaknya satu addon sebelum memuat baris katalog di Beranda.
+ Addon yang terpasang saat ini tidak mengekspos katalog yang kompatibel tanpa tambahan yang diperlukan.
+ Tidak ada baris beranda tersedia
+ Lihat Detail
+ Kontrol putar dan simpan.
+ Tindakan
+ Daftar pemeran utama.
+ Baris koleksi atau waralaba terkait.
+ Koleksi
+ Bagian komentar Trakt.
+ Durasi, status, rilis, bahasa, dan info terkait.
+ Detail
+ Musim dan daftar episode untuk serial.
+ Baris rekomendasi.
+ Lebih Seperti Ini
+ Sinopsis, rating, genre, dan kredit utama.
+ Ikhtisar
+ Studio dan jaringan.
+ Produksi
+ Baris trailer dan pintasan pemutaran.
+ Kembali online
+ Tidak dapat menjangkau server
+ Tidak ada koneksi internet
+ (usia %1$d)
+ Lahir %1$s%2$s
+ Meninggal %1$s
+ Dikenal karena: %1$s
+ Terbaru
+ Tidak dapat memuat detail untuk %1$s
+ Populer
+ Terjadi kesalahan
+ Mendatang
+ Hapus
+ Batal
+ Masukkan PIN
+ Masukkan PIN untuk %1$s
+ Lupa PIN?
+ PIN salah
+ Dikunci. Coba lagi dalam %1$dd
+ Pilihan avatar akan muncul di sini saat katalog dimuat.
+ Avatar: %1$s
+ Masukkan URL gambar http:// atau https:// yang valid.
+ Pilih avatar
+ Pilih avatar di bawah ini.
+ Buat Profil
+ URL avatar kustom dipilih.
+ URL avatar kustom
+ Tempelkan tautan gambar, atau kosongkan untuk menggunakan katalog avatar bawaan.
+ https://example.com/avatar.png
+ Semua data untuk \"%1$s\" akan dihapus secara permanen.
+ Hapus Profil
+ Tambah Profil
+ Edit Profil
+ Masukkan PIN saat ini
+ Masukkan PIN baru
+ Profil %1$d
+ Memuat avatar...
+ Kelola Profil
+ Nama profil
+ Profil baru
+ Addon utama nonaktif
+ Addon utama aktif
+ Hapus PIN untuk %1$s
+ Hapus Kunci PIN
+ Menyimpan...
+ Keamanan
+ Tambahkan PIN jika Anda ingin profil ini dikunci sebelum beralih ke dalamnya.
+ Profil ini dilindungi dengan PIN.
+ Pilih avatar untuk profil ini.
+ Atur Kunci PIN
+ Profil tanpa nama
+ Gunakan Addon Utama
+ Bagikan pengaturan addon profil utama alih-alih mengelola daftar terpisah.
+ Siapa yang menonton?
+ Diunduh
+ Lanjutkan
+ Scraper aktif
+ Memeriksa addon lainnya…
+ Salin tautan stream
+ Unduh file
+ Addon stream yang terpasang gagal mengembalikan respons stream yang valid.
+ Tidak dapat memuat stream
+ Pasang addon terlebih dahulu untuk memuat stream untuk judul ini.
+ Addon yang terpasang tidak menyediakan stream untuk tipe judul ini.
+ Tidak ada addon stream tersedia
+ Tidak ada addon yang terpasang yang mengembalikan stream untuk judul ini.
+ S%1$d E%2$d
+ Episode
+ S%1$dE%2$d - %3$s
+ Mengambil…
+ Mencari sumber…
+ Mencari stream…
+ Tautan stream disalin
+ Tidak ada tautan stream langsung tersedia
+ Tidak ada metadata tersedia
+ Perbarui stream
+ Lanjutkan dari %1$d%
+ Lanjutkan dari %1$s
+ UKURAN %1$s
+ Stream torrent tidak didukung
+ Tutup trailer
+ Tidak dapat memutar trailer
+ Gagal memuat daftar Trakt
+ Gagal memperbarui daftar Trakt
+ %1$s • %2$s
+ Pemeriksaan pembaruan gagal
+ Unduhan gagal
+ Mengunduh %1$d%
+ Tidak dapat memulai instalasi
+ Anda menggunakan versi terbaru.
+ Aktifkan instalasi aplikasi untuk Nuvio, lalu kembali dan lanjutkan.
+ Mengunduh pembaruan...
+ Tidak ada pembaruan ditemukan.
+ Versi baru siap untuk dipasang.
+ Pembaruan dalam aplikasi tidak tersedia di build ini.
+ Mempersiapkan unduhan
+ Catatan rilis
+ Izinkan instalasi untuk melanjutkan
+ Pembaruan tersedia
+ Status pembaruan
+ Addon tersebut sudah terpasang.
+ Masukkan URL addon yang valid
+ Tidak dapat memuat manifes
+ Nuvio
+ Penghapusan akun gagal
+ Masuk gagal
+ Keluar gagal
+ Pendaftaran gagal
+ Tidak dapat memuat item katalog.
+ Berikutnya
+ Berikutnya • S%1$dE%2$d
+ Logo %1$s
+ Gagal memuat komentar
+ Tidak dapat memuat detail dari addon mana pun.
+ Jaringan
+ Tidak ada addon yang menyediakan meta untuk konten ini.
+ Unduhan gagal
+ Menampilkan progres unduhan langsung dan kontrol.
+ Unduhan
+ Unduhan selesai
+ Mengunduh %1$s • %2$s
+ Mengunduh %1$s • %2$s / %3$s
+ Unduhan gagal
+ Dijeda %1$s
+ Hapus
+ Hapus %1$s dari %2$s?
+ Hapus %1$s dari perpustakaan Anda?
+ Hapus dari Perpustakaan?
+ Film
+ Peringatan saat episode baru dari acara yang disimpan dirilis.
+ Pratinjau peringatan rilis episode.
+ Gagal mengirim notifikasi uji coba.
+ Notifikasi uji coba terkirim untuk %1$s.
+ Tidak dapat memutar stream ini.
+ PIN profil ini berubah. Hubungkan sekali untuk menyegarkan kunci di perangkat ini.
+ Tidak dapat menghapus kunci PIN. Coba lagi.
+ Hubungkan ke internet untuk menghapus kunci PIN.
+ PIN ini belum bisa diverifikasi offline di perangkat ini. Hubungkan sekali dan buka kunci secara online terlebih dahulu.
+ Tidak dapat mengatur PIN. Coba lagi.
+ Hubungkan ke internet untuk mengatur PIN.
+ Profil ini menggunakan addon utama.
+ Gagal memuat %1$s
+ Stream
+ Tertanam
+ Otorisasi ditolak
+ Selesaikan masuk Trakt di browser Anda
+ Callback Trakt tidak valid
+ Status callback Trakt tidak valid
+ Respons token Trakt tidak valid
+ Gagal memuat perpustakaan Trakt
+ Daftar %1$d
+ Trakt tidak mengembalikan kode otorisasi
+ Kredensial Trakt hilang
+ Gagal memuat progres Trakt
+ Gagal menyelesaikan masuk Trakt
+ Pengguna Trakt
+ Daftar Tonton
+ Trailer
+ Tidak Diketahui
+ Addon
+ Disimpan
+ Putar %1$s
+ Lanjutkan %1$s
+ JSON kosong.
+ Koleksi %1$d memiliki id kosong.
+ Koleksi \'%1$s\' memiliki judul kosong.
+ Folder %1$d di \'%2$s\' memiliki id kosong.
+ Folder \'%1$s\' di \'%2$s\' memiliki judul kosong.
+ Sumber %1$d di folder \'%2$s\' memiliki kolom kosong.
+ Sumber %1$d di folder \'%2$s\' tidak memiliki ID daftar Trakt.
+ JSON tidak valid: %1$s
+ Addon tidak ditemukan: %1$s
+ Januari
+ Februari
+ Maret
+ April
+ Mei
+ Juni
+ Juli
+ Agustus
+ September
+ Oktober
+ November
+ Desember
+ Jan
+ Feb
+ Mar
+ Apr
+ Mei
+ Jun
+ Jul
+ Agu
+ Sep
+ Okt
+ Nov
+ Des
+ Perusahaan Produksi
+ Jaringan
+ Tidak dapat memuat %1$s
+ Populer
+ Terbaru
+ %1$s • %2$s
+ Nilai Tertinggi
+ Sertifikasi
+ Detail Film
+ Bahasa Asli
+ Negara Asal
+ Info Rilis
+ Durasi
+ Poster
+ Teks
+ Detail Acara
+ Status
+ Video
+ FILE
+ Tidak ada tautan stream langsung tersedia
+ Menggantikan unduhan sebelumnya
+ Unduhan dimulai
+ Format stream tidak didukung untuk unduhan
+ Badan respons kosong
+ Permintaan gagal dengan HTTP %1$d
+ Sistem unduhan belum diinisialisasi
+ Permintaan unduhan gagal
+ %1$s - %2$s
+ Judul yang disimpan akan muncul di sini setelah Anda mengetuk Simpan di layar detail.
+ Perpustakaan Anda kosong
+ Tidak dapat memuat perpustakaan
+ Lainnya
+ Perpustakaan
+ Hubungkan Trakt dan simpan judul ke daftar tonton atau daftar pribadi Anda.
+ Perpustakaan Trakt Anda kosong
+ Tidak dapat memuat perpustakaan Trakt
+ Perpustakaan Trakt
+ Anime
+ Saluran
+ Film
+ Serial
+ TV
+ %1$s sudah tersedia
+ %1$s • %2$s sudah tersedia
+ Episode baru sudah tersedia
+ %1$s sudah tersedia
+ Rilis Episode
+ Kreator
+ Sutradara
+ Penulis
+ Skor Penonton
+ Tidak ada stream trailer yang dapat diputar ditemukan.
+ Musim %1$d - %2$s
+ B
+ KB
+ MB
+ GB
+
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 8ed05d6c..5713e531 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -702,6 +702,11 @@
%1$d hours
Use libass for ASS/SSA subtitles
Experimental: advanced ASS/SSA rendering (styles, positioning, animations)
+ External Player
+ External Player App
+ Open new playback with Android's default video app or system chooser.
+ Open new playback with the selected installed player.
+ No supported external players installed
Hold Speed
Hold To Speed
Long-press anywhere on the player surface to temporarily boost playback speed.
@@ -1094,6 +1099,8 @@
Checking more addons…
Copy stream link
Download file
+ Open in external player
+ Open in internal player
The installed stream addons failed to return a valid stream response.
Could not load streams
Install an addon first to load streams for this title.
@@ -1114,6 +1121,9 @@
Resume from %1$s
SIZE %1$s
Torrent streams are not supported
+ Couldn't open external player
+ Choose an external player in settings first
+ No external player is available
Close trailer
Unable to play trailer
Failed to load Trakt lists
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 2d1fbaad..2069679d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -128,6 +128,9 @@ import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
+import com.nuvio.app.features.player.ExternalPlayerOpenResult
+import com.nuvio.app.features.player.ExternalPlayerPlatform
+import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository
@@ -288,6 +291,14 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) {
NativeNavigationTab.Settings -> AppScreenTab.Settings
}
+private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest =
+ ExternalPlayerPlaybackRequest(
+ sourceUrl = sourceUrl,
+ title = title,
+ streamTitle = streamTitle,
+ sourceHeaders = sourceHeaders,
+ )
+
private enum class AppGateScreen {
Loading,
Auth,
@@ -520,6 +531,7 @@ private fun MainAppContent(
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
+ var searchFocusRequestCount by remember { mutableStateOf(0) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle()
val liquidGlassNativeTabBarEnabled by remember {
@@ -562,6 +574,9 @@ private fun MainAppContent(
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
+ val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured)
+ val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable)
+ val externalPlayerFailedText = stringResource(Res.string.external_player_failed)
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
@@ -583,6 +598,9 @@ private fun MainAppContent(
LaunchedEffect(selectedTab) {
NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab())
+ if (selectedTab != AppScreenTab.Search) {
+ searchFocusRequestCount = 0
+ }
}
DisposableEffect(
@@ -752,6 +770,29 @@ private fun MainAppContent(
}
}
+ fun openExternalPlayback(launch: PlayerLaunch): Boolean {
+ return when (
+ ExternalPlayerPlatform.open(
+ request = launch.toExternalPlayerPlaybackRequest(),
+ playerId = playerSettingsUiState.externalPlayerId,
+ )
+ ) {
+ ExternalPlayerOpenResult.Opened -> true
+ ExternalPlayerOpenResult.NotConfigured -> {
+ NuvioToastController.show(externalPlayerNotConfiguredText)
+ false
+ }
+ ExternalPlayerOpenResult.NoPlayerAvailable -> {
+ NuvioToastController.show(externalPlayerUnavailableText)
+ false
+ }
+ ExternalPlayerOpenResult.Failed -> {
+ NuvioToastController.show(externalPlayerFailedText)
+ false
+ }
+ }
+ }
+
fun launchPlaybackWithDownloadPreference(
type: String,
videoId: String,
@@ -783,8 +824,7 @@ private fun MainAppContent(
)
val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri)
if (!localSourceUrl.isNullOrBlank()) {
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = title,
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
@@ -807,8 +847,12 @@ private fun MainAppContent(
parentMetaType = parentMetaType,
initialPositionMs = targetResumePositionMs,
initialProgressFraction = targetResumeProgressFraction,
- ),
- )
+ )
+ if (playerSettingsUiState.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
return
}
@@ -1009,7 +1053,13 @@ private fun MainAppContent(
)
NavItem(
selected = selectedTab == AppScreenTab.Search,
- onClick = { selectedTab = AppScreenTab.Search },
+ onClick = {
+ if (selectedTab == AppScreenTab.Search) {
+ searchFocusRequestCount++
+ } else {
+ selectedTab = AppScreenTab.Search
+ }
+ },
icon = Res.drawable.sidebar_search,
contentDescription = stringResource(Res.string.compose_nav_search),
)
@@ -1043,6 +1093,7 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
+ searchFocusRequestCount = searchFocusRequestCount,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
@@ -1097,7 +1148,13 @@ private fun MainAppContent(
if (isTabletLayout && !useNativeBottomTabs) {
TabletFloatingTopBar(
selectedTab = selectedTab,
- onTabSelected = { selectedTab = it },
+ onTabSelected = { tab ->
+ if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) {
+ searchFocusRequestCount++
+ } else {
+ selectedTab = tab
+ }
+ },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
@@ -1348,10 +1405,8 @@ private fun MainAppContent(
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
- reuseNavigated = true
StreamsRepository.clear()
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = launch.title,
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
@@ -1376,7 +1431,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
- )
+ if (playerSettings.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ reuseNavigated = true
+ return@LaunchedEffect
+ }
+ reuseNavigated = true
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo { inclusive = true }
}
@@ -1428,8 +1489,7 @@ private fun MainAppContent(
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = launch.title,
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
@@ -1454,9 +1514,13 @@ private fun MainAppContent(
initialPositionMs = launch.resumePositionMs ?: 0L,
initialProgressFraction = launch.resumeProgressFraction,
)
- )
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
+ if (playerSettings.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return@LaunchedEffect
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo { inclusive = true }
}
@@ -1472,6 +1536,74 @@ private fun MainAppContent(
return@composable
}
+ fun openSelectedStream(
+ stream: com.nuvio.app.features.streams.StreamItem,
+ resolvedResumePositionMs: Long?,
+ resolvedResumeProgressFraction: Float?,
+ forceExternal: Boolean,
+ forceInternal: Boolean,
+ ) {
+ val sourceUrl = stream.directPlaybackUrl ?: return
+ if (playerSettings.streamReuseLastLinkEnabled) {
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
+ StreamLinkCacheRepository.save(
+ contentKey = cacheKey,
+ url = sourceUrl,
+ streamName = stream.streamLabel,
+ addonName = stream.addonName,
+ addonId = stream.addonId,
+ requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
+ responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
+ filename = stream.behaviorHints.filename,
+ videoSize = stream.behaviorHints.videoSize,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ )
+ }
+ val playerLaunch = PlayerLaunch(
+ title = launch.title,
+ sourceUrl = sourceUrl,
+ sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
+ sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
+ logo = launch.logo,
+ poster = launch.poster,
+ background = launch.background,
+ seasonNumber = launch.seasonNumber,
+ episodeNumber = launch.episodeNumber,
+ episodeTitle = launch.episodeTitle,
+ episodeThumbnail = launch.episodeThumbnail,
+ streamTitle = stream.streamLabel,
+ streamSubtitle = stream.streamSubtitle,
+ bingeGroup = stream.behaviorHints.bingeGroup,
+ pauseDescription = pauseDescription,
+ providerName = stream.addonName,
+ providerAddonId = stream.addonId,
+ contentType = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId ?: effectiveVideoId,
+ parentMetaType = launch.parentMetaType ?: launch.type,
+ initialPositionMs = resolvedResumePositionMs ?: 0L,
+ initialProgressFraction = resolvedResumeProgressFraction,
+ )
+
+ if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
+ openExternalPlayback(playerLaunch)
+ StreamsRepository.cancelLoading()
+ return
+ }
+
+ val launchId = PlayerLaunchStore.put(playerLaunch)
+ StreamsRepository.cancelLoading()
+ navController.navigate(
+ PlayerRoute(launchId = launchId)
+ )
+ }
+
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
@@ -1490,62 +1622,22 @@ private fun MainAppContent(
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
- val sourceUrl = stream.directPlaybackUrl
- if (sourceUrl != null) {
- // Persist for Reuse Last Link
- if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(
- type = launch.type,
- videoId = effectiveVideoId,
- parentMetaId = launch.parentMetaId,
- season = launch.seasonNumber,
- episode = launch.episodeNumber,
- )
- StreamLinkCacheRepository.save(
- contentKey = cacheKey,
- url = sourceUrl,
- streamName = stream.streamLabel,
- addonName = stream.addonName,
- addonId = stream.addonId,
- requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- filename = stream.behaviorHints.filename,
- videoSize = stream.behaviorHints.videoSize,
- bingeGroup = stream.behaviorHints.bingeGroup,
- )
- }
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
- title = launch.title,
- sourceUrl = sourceUrl,
- sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
- sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
- logo = launch.logo,
- poster = launch.poster,
- background = launch.background,
- seasonNumber = launch.seasonNumber,
- episodeNumber = launch.episodeNumber,
- episodeTitle = launch.episodeTitle,
- episodeThumbnail = launch.episodeThumbnail,
- streamTitle = stream.streamLabel,
- streamSubtitle = stream.streamSubtitle,
- bingeGroup = stream.behaviorHints.bingeGroup,
- pauseDescription = pauseDescription,
- providerName = stream.addonName,
- providerAddonId = stream.addonId,
- contentType = launch.type,
- videoId = effectiveVideoId,
- parentMetaId = launch.parentMetaId ?: effectiveVideoId,
- parentMetaType = launch.parentMetaType ?: launch.type,
- initialPositionMs = resolvedResumePositionMs ?: 0L,
- initialProgressFraction = resolvedResumeProgressFraction,
- )
- )
- StreamsRepository.cancelLoading()
- navController.navigate(
- PlayerRoute(launchId = launchId)
- )
- }
+ openSelectedStream(
+ stream = stream,
+ resolvedResumePositionMs = resolvedResumePositionMs,
+ resolvedResumeProgressFraction = resolvedResumeProgressFraction,
+ forceExternal = false,
+ forceInternal = false,
+ )
+ },
+ onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
+ openSelectedStream(
+ stream = stream,
+ resolvedResumePositionMs = resolvedResumePositionMs,
+ resolvedResumeProgressFraction = resolvedResumeProgressFraction,
+ forceExternal = openExternally,
+ forceInternal = !openExternally,
+ )
},
onBack = {
StreamsRepository.clear()
@@ -1674,8 +1766,7 @@ private fun MainAppContent(
?.let(WatchProgressRepository::progressForVideo)
?.takeIf { it.isResumable }
- val launchId = PlayerLaunchStore.put(
- PlayerLaunch(
+ val playerLaunch = PlayerLaunch(
title = item.title,
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
@@ -1697,8 +1788,12 @@ private fun MainAppContent(
parentMetaType = item.parentMetaType,
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
- ),
)
+ if (playerSettingsUiState.externalPlayerEnabled) {
+ openExternalPlayback(playerLaunch)
+ return@DownloadsScreen
+ }
+ val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId))
},
)
@@ -2007,6 +2102,7 @@ private fun rememberGuardedPopBackStack(
private fun AppTabHost(
selectedTab: AppScreenTab,
modifier: Modifier = Modifier,
+ searchFocusRequestCount: Int = 0,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
@@ -2054,6 +2150,7 @@ private fun AppTabHost(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
+ searchFocusRequestCount = searchFocusRequestCount,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
index 9bf4d92a..c200b6af 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
@@ -25,6 +25,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@@ -37,11 +39,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
-import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.posterCardClickable
+import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlin.math.roundToInt
@@ -51,6 +54,19 @@ import org.jetbrains.compose.resources.stringResource
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
+@Composable
+private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): String =
+ when {
+ item.seasonNumber != null && item.episodeNumber != null && item.isNextUp ->
+ stringResource(Res.string.continue_watching_up_next_episode, item.seasonNumber, item.episodeNumber)
+ item.seasonNumber != null && item.episodeNumber != null ->
+ stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber)
+ item.isNextUp ->
+ stringResource(Res.string.continue_watching_up_next)
+ else ->
+ stringResource(Res.string.media_movie)
+ }
+
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
useEpisodeThumbnails: Boolean,
): String? = when {
@@ -138,6 +154,11 @@ private fun HomeContinueWatchingSectionContent(
onItemClick: ((ContinueWatchingItem) -> Unit)?,
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
) {
+ val homeCatalogSettings by remember {
+ HomeCatalogSettingsRepository.snapshot()
+ HomeCatalogSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+
NuvioShelfSection(
title = stringResource(Res.string.compose_settings_page_continue_watching),
entries = items,
@@ -145,6 +166,7 @@ private fun HomeContinueWatchingSectionContent(
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
+ showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
key = { item -> item.videoId },
) { item ->
when (style) {
@@ -355,9 +377,8 @@ private fun ContinueWatchingWideCard(
.padding(layout.wideContentPadding),
verticalArrangement = Arrangement.SpaceBetween,
) {
- val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null
- val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank()
- val wideMetaLine = localizedContinueWatchingSubtitle(item)
+ val wideMetaLine = localizedContinueWatchingMetaLine(item)
+ val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -389,9 +410,9 @@ private fun ContinueWatchingWideCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
- if (hasEpisodeTitle) {
+ if (episodeTitle != null) {
Text(
- text = item.episodeTitle.orEmpty(),
+ text = episodeTitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = layout.wideMetaSize,
fontWeight = FontWeight.Medium,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
new file mode 100644
index 00000000..10a03f51
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt
@@ -0,0 +1,29 @@
+package com.nuvio.app.features.player
+
+data class ExternalPlayerApp(
+ val id: String,
+ val name: String,
+)
+
+data class ExternalPlayerPlaybackRequest(
+ val sourceUrl: String,
+ val title: String,
+ val streamTitle: String? = null,
+ val sourceHeaders: Map = emptyMap(),
+)
+
+enum class ExternalPlayerOpenResult {
+ Opened,
+ NotConfigured,
+ NoPlayerAvailable,
+ Failed,
+}
+
+internal expect object ExternalPlayerPlatform {
+ fun defaultPlayerId(): String?
+ fun availablePlayers(): List
+ fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt
index 48088665..6f5c2348 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt
@@ -186,9 +186,18 @@ val AvailableLanguageOptions: List = listOf(
LanguagePreferenceOption("zu", Res.string.lang_zulu),
)
-private val Iso639Aliases = mapOf(
+private val LanguageCodeAliases = mapOf(
+ "pt-pt" to "pt",
+ "pt_br" to "pt-BR",
+ "pt-br" to "pt-BR",
+ "br" to "pt-BR",
+ "pob" to "pt-BR",
"eng" to "en",
"spa" to "es",
+ "es-419" to "es-419",
+ "es_419" to "es-419",
+ "es-la" to "es-419",
+ "es-lat" to "es-419",
"fra" to "fr",
"fre" to "fr",
"deu" to "de",
@@ -200,14 +209,170 @@ private val Iso639Aliases = mapOf(
"kor" to "ko",
"zho" to "zh",
"chi" to "zh",
+ "zht" to "zh-TW",
+ "zhs" to "zh-CN",
+ "chi-tw" to "zh-TW",
+ "chi-cn" to "zh-CN",
+ "zh-tw" to "zh-TW",
+ "zh_tw" to "zh-TW",
+ "zh-cn" to "zh-CN",
+ "zh_cn" to "zh-CN",
"ara" to "ar",
"hin" to "hi",
"nld" to "nl",
"dut" to "nl",
"pol" to "pl",
"swe" to "sv",
+ "nor" to "no",
+ "dan" to "da",
+ "fin" to "fi",
"tur" to "tr",
+ "ell" to "el",
+ "gre" to "el",
"heb" to "he",
+ "tha" to "th",
+ "vie" to "vi",
+ "ind" to "id",
+ "msa" to "ms",
+ "may" to "ms",
+ "ces" to "cs",
+ "cze" to "cs",
+ "hun" to "hu",
+ "ron" to "ro",
+ "rum" to "ro",
+ "ukr" to "uk",
+ "bul" to "bg",
+ "hrv" to "hr",
+ "srp" to "sr",
+ "slk" to "sk",
+ "slo" to "sk",
+ "slv" to "sl",
+ "cat" to "ca",
+ "alb" to "sq",
+ "sqi" to "sq",
+ "bos" to "bs",
+ "mac" to "mk",
+ "mkd" to "mk",
+ "lav" to "lv",
+ "lit" to "lt",
+ "est" to "et",
+ "isl" to "is",
+ "ice" to "is",
+ "glg" to "gl",
+ "baq" to "eu",
+ "eus" to "eu",
+ "wel" to "cy",
+ "cym" to "cy",
+ "gle" to "ga",
+ "ben" to "bn",
+ "tam" to "ta",
+ "tel" to "te",
+ "mal" to "ml",
+ "kan" to "kn",
+ "mar" to "mr",
+ "pan" to "pa",
+ "guj" to "gu",
+ "urd" to "ur",
+ "fas" to "fa",
+ "per" to "fa",
+ "amh" to "am",
+ "swa" to "sw",
+ "zul" to "zu",
+ "afr" to "af",
+ "mlt" to "mt",
+ "bel" to "be",
+ "geo" to "ka",
+ "kat" to "ka",
+ "arm" to "hy",
+ "hye" to "hy",
+ "aze" to "az",
+ "kaz" to "kk",
+ "uzb" to "uz",
+ "mon" to "mn",
+ "khm" to "km",
+ "lao" to "lo",
+ "mya" to "my",
+ "bur" to "my",
+ "sin" to "si",
+ "nep" to "ne",
+ "tgl" to "tl",
+ "fil" to "tl",
+)
+
+private val LanguageNameAliases = mapOf(
+ "afrikaans" to "af",
+ "albanian" to "sq",
+ "amharic" to "am",
+ "arabic" to "ar",
+ "armenian" to "hy",
+ "azerbaijani" to "az",
+ "basque" to "eu",
+ "belarusian" to "be",
+ "bengali" to "bn",
+ "bosnian" to "bs",
+ "bulgarian" to "bg",
+ "burmese" to "my",
+ "catalan" to "ca",
+ "chinese" to "zh",
+ "mandarin" to "zh",
+ "croatian" to "hr",
+ "czech" to "cs",
+ "danish" to "da",
+ "dutch" to "nl",
+ "english" to "en",
+ "estonian" to "et",
+ "filipino" to "tl",
+ "finnish" to "fi",
+ "french" to "fr",
+ "galician" to "gl",
+ "georgian" to "ka",
+ "german" to "de",
+ "greek" to "el",
+ "gujarati" to "gu",
+ "hebrew" to "he",
+ "hindi" to "hi",
+ "hungarian" to "hu",
+ "icelandic" to "is",
+ "indonesian" to "id",
+ "irish" to "ga",
+ "italian" to "it",
+ "japanese" to "ja",
+ "kannada" to "kn",
+ "kazakh" to "kk",
+ "khmer" to "km",
+ "korean" to "ko",
+ "lao" to "lo",
+ "latvian" to "lv",
+ "lithuanian" to "lt",
+ "macedonian" to "mk",
+ "malay" to "ms",
+ "malayalam" to "ml",
+ "maltese" to "mt",
+ "marathi" to "mr",
+ "mongolian" to "mn",
+ "nepali" to "ne",
+ "norwegian" to "no",
+ "persian" to "fa",
+ "polish" to "pl",
+ "punjabi" to "pa",
+ "romanian" to "ro",
+ "russian" to "ru",
+ "serbian" to "sr",
+ "sinhala" to "si",
+ "slovak" to "sk",
+ "slovenian" to "sl",
+ "swahili" to "sw",
+ "swedish" to "sv",
+ "tamil" to "ta",
+ "telugu" to "te",
+ "thai" to "th",
+ "turkish" to "tr",
+ "ukrainian" to "uk",
+ "urdu" to "ur",
+ "uzbek" to "uz",
+ "vietnamese" to "vi",
+ "welsh" to "cy",
+ "zulu" to "zu",
)
fun normalizeLanguageCode(language: String?): String? {
@@ -218,13 +383,55 @@ fun normalizeLanguageCode(language: String?): String? {
?.takeIf { it.isNotBlank() }
?: return null
+ val tokenized = raw
+ .replace('-', ' ')
+ .replace('.', ' ')
+ .replace('/', ' ')
+ .replace(Regex("\\s+"), " ")
+ .trim()
+
+ fun containsAny(vararg values: String): Boolean =
+ values.any { value -> tokenized.contains(value) }
+
+ if (containsAny("portuguese", "portugues")) {
+ return when {
+ containsAny("brazil", "brasil", "brazilian", "brasileiro", "pt br", "ptbr", "pob", "(br)") ->
+ "pt-br"
+ containsAny("portugal", "european", "europeu", "iberian", "pt pt", "ptpt") ->
+ "pt"
+ else -> "pt"
+ }
+ }
+
+ if (containsAny("spanish", "espanol", "castellano")) {
+ return if (containsAny("latin", "latino", "latinoamerica", "latinoamericano", "lat am", "latam", "es 419", "es419", "(419)")) {
+ "es-419"
+ } else {
+ "es"
+ }
+ }
+
+ LanguageCodeAliases[raw]?.let { return it.replace('_', '-').lowercase() }
+ LanguageNameAliases[tokenized]?.let { return it }
+ LanguageNameAliases.entries
+ .sortedByDescending { it.key.length }
+ .firstOrNull { (name, _) ->
+ tokenized == name ||
+ tokenized.startsWith("$name ") ||
+ tokenized.endsWith(" $name") ||
+ tokenized.contains(" $name ")
+ }
+ ?.let { return it.value }
+
val primary = raw.substringBefore('-')
- val canonicalPrimary = Iso639Aliases[primary] ?: primary
+ val primaryAlias = LanguageCodeAliases[primary]?.replace('_', '-')?.lowercase()
val suffix = raw.substringAfter('-', "")
return if (suffix.isBlank()) {
- canonicalPrimary
+ primaryAlias ?: primary
+ } else if (primaryAlias != null && !primaryAlias.contains('-')) {
+ "$primaryAlias-$suffix"
} else {
- "$canonicalPrimary-$suffix"
+ primaryAlias ?: "$primary-$suffix"
}
}
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 9db6838d..50c727c1 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
@@ -39,6 +39,8 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.AddonResource
+import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo
@@ -439,8 +441,24 @@ fun PlayerScreen(
var preferredSubtitleSelectionApplied by rememberSaveable(sourceUrl) { mutableStateOf(false) }
var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) }
val subtitleStyle = playerSettingsUiState.subtitleStyle
+ val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
+ val activeAddonSubtitleType = contentType ?: parentMetaType
+ val addonSubtitleFetchKey = remember(
+ addonsUiState.addons,
+ activeAddonSubtitleType,
+ activeVideoId,
+ ) {
+ buildAddonSubtitleFetchKey(
+ addons = addonsUiState.addons,
+ type = activeAddonSubtitleType,
+ videoId = activeVideoId,
+ )
+ }
+ var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) {
+ mutableStateOf(null)
+ }
fun refreshTracks() {
val ctrl = playerController ?: return
@@ -1092,8 +1110,8 @@ fun PlayerScreen(
}
fun fetchAddonSubtitlesForActiveItem() {
- val type = contentType ?: return
- val videoId = activeVideoId ?: return
+ val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
+ val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
SubtitleRepository.fetchAddonSubtitles(type, videoId)
}
@@ -1127,11 +1145,11 @@ fun PlayerScreen(
playerController?.applySubtitleStyle(subtitleStyle)
}
- LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) {
- if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect
- if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) {
- fetchAddonSubtitlesForActiveItem()
- }
+ LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
+ val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
+ if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
+ autoFetchedAddonSubtitlesForKey = fetchKey
+ fetchAddonSubtitlesForActiveItem()
}
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
@@ -1924,6 +1942,47 @@ fun PlayerScreen(
}
}
+private fun buildAddonSubtitleFetchKey(
+ addons: List,
+ type: String?,
+ videoId: String?,
+): String? {
+ val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null
+ val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null
+ val compatibleSubtitleAddons = addons.mapNotNull { addon ->
+ val manifest = addon.manifest ?: return@mapNotNull null
+ val supportsSubtitles = manifest.resources.any { resource ->
+ resource.isCompatibleSubtitleResource(
+ type = normalizedType,
+ videoId = normalizedVideoId,
+ )
+ }
+ if (!supportsSubtitles) return@mapNotNull null
+ "${manifest.id}:${manifest.transportUrl}"
+ }
+
+ if (compatibleSubtitleAddons.isEmpty()) return null
+ return buildString {
+ append(normalizedType)
+ append('|')
+ append(normalizedVideoId)
+ append('|')
+ append(compatibleSubtitleAddons.sorted().joinToString("|"))
+ }
+}
+
+private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean {
+ val isSubtitleResource = name.equals("subtitles", ignoreCase = true) ||
+ name.equals("subtitle", ignoreCase = true)
+ if (!isSubtitleResource) return false
+
+ val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type
+ val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) }
+ if (!typeMatches) return false
+
+ return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
+}
+
private fun findPreferredTrackIndex(
tracks: List,
targets: List,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
index ec58911e..15f4f4d7 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt
@@ -13,6 +13,8 @@ data class PlayerSettingsUiState(
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
val holdToSpeedEnabled: Boolean = true,
val holdToSpeedValue: Float = 2f,
+ val externalPlayerEnabled: Boolean = false,
+ val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
val secondaryPreferredAudioLanguage: String? = null,
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
@@ -52,6 +54,8 @@ object PlayerSettingsRepository {
private var resizeMode = PlayerResizeMode.Fit
private var holdToSpeedEnabled = true
private var holdToSpeedValue = 2f
+ private var externalPlayerEnabled = false
+ private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
private var preferredAudioLanguage = AudioLanguageOption.DEVICE
private var secondaryPreferredAudioLanguage: String? = null
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@@ -96,6 +100,8 @@ object PlayerSettingsRepository {
resizeMode = PlayerResizeMode.Fit
holdToSpeedEnabled = true
holdToSpeedValue = 2f
+ externalPlayerEnabled = false
+ externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage = AudioLanguageOption.DEVICE
secondaryPreferredAudioLanguage = null
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
@@ -135,6 +141,9 @@ object PlayerSettingsRepository {
?: PlayerResizeMode.Fit
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
+ externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
+ externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
+ ?: ExternalPlayerPlatform.defaultPlayerId()
preferredAudioLanguage =
normalizeLanguageCode(PlayerSettingsStorage.loadPreferredAudioLanguage())
?: AudioLanguageOption.DEVICE
@@ -231,6 +240,31 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
}
+ fun setExternalPlayerEnabled(enabled: Boolean) {
+ ensureLoaded()
+ if (enabled && externalPlayerId.isNullOrBlank()) {
+ externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
+ ?: ExternalPlayerPlatform.availablePlayers().firstOrNull()?.id
+ PlayerSettingsStorage.saveExternalPlayerId(externalPlayerId)
+ }
+ if (externalPlayerEnabled == enabled) {
+ publish()
+ return
+ }
+ externalPlayerEnabled = enabled
+ publish()
+ PlayerSettingsStorage.saveExternalPlayerEnabled(enabled)
+ }
+
+ fun setExternalPlayerId(playerId: String?) {
+ ensureLoaded()
+ val normalized = playerId?.takeIf { it.isNotBlank() }
+ if (externalPlayerId == normalized) return
+ externalPlayerId = normalized
+ publish()
+ PlayerSettingsStorage.saveExternalPlayerId(normalized)
+ }
+
fun setPreferredAudioLanguage(language: String) {
ensureLoaded()
val normalized = normalizeLanguageCode(language) ?: AudioLanguageOption.DEVICE
@@ -470,6 +504,8 @@ object PlayerSettingsRepository {
resizeMode = resizeMode,
holdToSpeedEnabled = holdToSpeedEnabled,
holdToSpeedValue = holdToSpeedValue,
+ externalPlayerEnabled = externalPlayerEnabled,
+ externalPlayerId = externalPlayerId,
preferredAudioLanguage = preferredAudioLanguage,
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
preferredSubtitleLanguage = preferredSubtitleLanguage,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
index efc6b6c2..5c3b3756 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt
@@ -11,6 +11,10 @@ internal expect object PlayerSettingsStorage {
fun saveHoldToSpeedEnabled(enabled: Boolean)
fun loadHoldToSpeedValue(): Float?
fun saveHoldToSpeedValue(speed: Float)
+ fun loadExternalPlayerEnabled(): Boolean?
+ fun saveExternalPlayerEnabled(enabled: Boolean)
+ fun loadExternalPlayerId(): String?
+ fun saveExternalPlayerId(playerId: String?)
fun loadPreferredAudioLanguage(): String?
fun savePreferredAudioLanguage(language: String)
fun loadSecondaryPreferredAudioLanguage(): String?
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
index a6991e74..64d8c879 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
@@ -1,10 +1,13 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -15,6 +18,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -35,8 +39,12 @@ object SubtitleRepository {
private val _error = MutableStateFlow(null)
val error: StateFlow = _error.asStateFlow()
+ private var activeFetchJob: Job? = null
+
fun fetchAddonSubtitles(type: String, videoId: String) {
- scope.launch {
+ activeFetchJob?.cancel()
+ activeFetchJob = scope.launch {
+ val requestType = canonicalSubtitleType(type)
_isLoading.value = true
_error.value = null
_addonSubtitles.value = emptyList()
@@ -46,17 +54,13 @@ object SubtitleRepository {
for (addon in addons) {
val manifest = addon.manifest ?: continue
- val subtitleResource = manifest.resources.find { it.name == "subtitles" } ?: continue
- if (!subtitleResource.types.contains(type)) continue
-
- val prefixMatch = subtitleResource.idPrefixes.isEmpty() ||
- subtitleResource.idPrefixes.any { videoId.startsWith(it) }
- if (!prefixMatch) continue
+ val subtitleResource = manifest.resources.find { it.name.isSubtitleResourceName() } ?: continue
+ if (!subtitleResource.supportsSubtitleType(requestType, videoId)) continue
val subtitleUrl = buildAddonResourceUrl(
manifestUrl = manifest.transportUrl,
resource = "subtitles",
- type = type,
+ type = requestType,
id = videoId,
)
@@ -69,21 +73,23 @@ object SubtitleRepository {
for (element in subtitlesArray) {
val obj = element.jsonObject
- val id = obj["id"]?.jsonPrimitive?.content
+ val id = obj.stringValue("id")
?: "${manifest.id}_${allSubs.size}"
- val url = obj["url"]?.jsonPrimitive?.content ?: continue
- val lang = obj["lang"]?.jsonPrimitive?.content ?: "unknown"
+ val url = obj.stringValue("url") ?: continue
+ val rawLang = obj.subtitleLanguage() ?: "unknown"
+ val normalizedLang = normalizeLanguageCode(rawLang) ?: rawLang
allSubs.add(
AddonSubtitle(
id = id,
url = url,
- language = lang,
- display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})",
+ language = normalizedLang,
+ display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
)
)
}
- } catch (_: Throwable) {
+ } catch (error: Throwable) {
+ if (error is CancellationException) throw error
}
}
@@ -96,8 +102,35 @@ object SubtitleRepository {
}
fun clear() {
+ activeFetchJob?.cancel()
_addonSubtitles.value = emptyList()
_isLoading.value = false
_error.value = null
}
}
+
+private fun canonicalSubtitleType(type: String): String =
+ if (type.equals("tv", ignoreCase = true)) "series" else type.lowercase()
+
+private fun String.isSubtitleResourceName(): Boolean =
+ equals("subtitles", ignoreCase = true) || equals("subtitle", ignoreCase = true)
+
+private fun AddonResource.supportsSubtitleType(type: String, videoId: String): Boolean {
+ val typeMatches = types.isEmpty() || types.any { it.equals(type, ignoreCase = true) }
+ if (!typeMatches) return false
+ return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) }
+}
+
+private fun JsonObject.subtitleLanguage(): String? =
+ stringValue("lang")
+ ?: stringValue("language")
+ ?: stringValue("languageCode")
+ ?: stringValue("locale")
+ ?: stringValue("label")
+
+private fun JsonObject.stringValue(name: String): String? =
+ this[name]
+ ?.jsonPrimitive
+ ?.contentOrNull
+ ?.trim()
+ ?.takeIf { it.isNotBlank() }
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
index 26a3c82f..3d5cc814 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt
@@ -33,6 +33,8 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.font.FontWeight
@@ -80,7 +82,16 @@ fun SearchScreen(
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
+ searchFocusRequestCount: Int = 0,
) {
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(searchFocusRequestCount) {
+ if (searchFocusRequestCount > 0) {
+ focusRequester.requestFocus()
+ }
+ }
+
LaunchedEffect(Unit) {
AddonRepository.initialize()
WatchedRepository.ensureLoaded()
@@ -240,6 +251,7 @@ fun SearchScreen(
value = query,
onValueChange = { query = it },
placeholder = stringResource(Res.string.compose_search_placeholder),
+ modifier = Modifier.focusRequester(focusRequester),
trailingContent = if (query.isNotBlank()) {
{
IconButton(onClick = { query = "" }) {
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 e629434d..679054c2 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
@@ -1,32 +1,34 @@
package com.nuvio.app.features.settings
import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.lang_czech
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
-import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_greek
+import nuvio.composeapp.generated.resources.lang_indonesian
+import nuvio.composeapp.generated.resources.lang_italian
import nuvio.composeapp.generated.resources.lang_polish
-import nuvio.composeapp.generated.resources.lang_czech
+import nuvio.composeapp.generated.resources.lang_portuguese_portugal
+import nuvio.composeapp.generated.resources.lang_spanish
+import nuvio.composeapp.generated.resources.lang_turkish
import org.jetbrains.compose.resources.StringResource
enum class AppLanguage(
val code: String,
val labelRes: StringResource,
) {
+ CZECH("cs", Res.string.lang_czech),
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),
- ITALIAN("it", Res.string.lang_italian),
GREEK("el", Res.string.lang_greek),
+ INDONESIAN("id", Res.string.lang_indonesian),
+ ITALIAN("it", Res.string.lang_italian),
POLISH("pl", Res.string.lang_polish),
- CZECH("cs", Res.string.lang_czech),
+ PORTUGUESE("pt", Res.string.lang_portuguese_portugal),
+ SPANISH("es", Res.string.lang_spanish),
+ TURKISH("tr", Res.string.lang_turkish),
;
companion object {
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 18a9c422..042d592d 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
@@ -53,6 +53,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
+import com.nuvio.app.features.player.ExternalPlayerApp
+import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
@@ -169,6 +171,7 @@ private fun PlaybackSettingsSection(
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
+ var showExternalPlayerDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
var showHoldToSpeedValueDialog by remember { mutableStateOf(false) }
@@ -180,6 +183,10 @@ private fun PlaybackSettingsSection(
var showAutoPlayRegexDialog by remember { mutableStateOf(false) }
val pluginsEnabled = AppFeaturePolicy.pluginsEnabled
val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
+ val availableExternalPlayers = ExternalPlayerPlatform.availablePlayers()
+ val selectedExternalPlayer = availableExternalPlayers.firstOrNull {
+ it.id == autoPlayPlayerSettings.externalPlayerId
+ }
val addonUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val pluginUiState = if (pluginsEnabled) {
val state by PluginRepository.uiState.collectAsStateWithLifecycle()
@@ -206,6 +213,39 @@ private fun PlaybackSettingsSection(
onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay,
)
SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_playback_external_player),
+ description = stringResource(
+ if (isIos) {
+ Res.string.settings_playback_external_player_description_ios
+ } else {
+ Res.string.settings_playback_external_player_description_android
+ },
+ ),
+ checked = autoPlayPlayerSettings.externalPlayerEnabled,
+ isTablet = isTablet,
+ onCheckedChange = { enabled ->
+ PlayerSettingsRepository.setExternalPlayerEnabled(enabled)
+ if (enabled && isIos) {
+ showExternalPlayerDialog = true
+ }
+ },
+ )
+ if (isIos && autoPlayPlayerSettings.externalPlayerEnabled) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = stringResource(Res.string.settings_playback_external_player_app),
+ description = selectedExternalPlayer?.name
+ ?: if (availableExternalPlayers.isEmpty()) {
+ stringResource(Res.string.settings_playback_external_player_none_available)
+ } else {
+ stringResource(Res.string.settings_playback_not_set)
+ },
+ isTablet = isTablet,
+ onClick = { showExternalPlayerDialog = true },
+ )
+ }
+ SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_hold_to_speed),
description = stringResource(Res.string.settings_playback_hold_to_speed_description),
@@ -780,6 +820,18 @@ private fun PlaybackSettingsSection(
)
}
+ if (showExternalPlayerDialog) {
+ ExternalPlayerSelectionDialog(
+ players = availableExternalPlayers,
+ selectedPlayerId = autoPlayPlayerSettings.externalPlayerId,
+ onPlayerSelected = { playerId ->
+ PlayerSettingsRepository.setExternalPlayerId(playerId)
+ showExternalPlayerDialog = false
+ },
+ onDismiss = { showExternalPlayerDialog = false },
+ )
+ }
+
if (showDecoderPriorityDialog) {
DecoderPriorityDialog(
selectedPriority = decoderPriority,
@@ -904,6 +956,100 @@ private data class LanguageSelectionOption(
val label: String,
)
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun ExternalPlayerSelectionDialog(
+ players: List,
+ selectedPlayerId: String?,
+ onPlayerSelected: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BasicAlertDialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.settings_playback_external_player_app),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+
+ if (players.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.settings_playback_external_player_none_available),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ players.forEach { player ->
+ val isSelected = player.id == selectedPlayerId
+ val containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onPlayerSelected(player.id) },
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = player.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = stringResource(Res.string.settings_playback_dialog_close),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun LanguageSelectionDialog(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 381ba569..978ea2e2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -427,12 +427,21 @@ internal fun settingsSearchEntries(
pageLabel = playbackPage,
section = playbackPlayer,
icon = Icons.Rounded.PlayArrow,
- rows = listOf(
+ rows = listOfNotNull(
PlaybackSearchRow(
"loading-overlay",
stringResource(Res.string.settings_playback_show_loading_overlay),
stringResource(Res.string.settings_playback_show_loading_overlay_description),
),
+ PlaybackSearchRow(
+ "external-player",
+ stringResource(Res.string.settings_playback_external_player),
+ stringResource(Res.string.settings_playback_external_player_description_android),
+ ),
+ if (isIos) PlaybackSearchRow(
+ "external-player-app",
+ stringResource(Res.string.settings_playback_external_player_app),
+ ) else null,
PlaybackSearchRow(
"hold-to-speed",
stringResource(Res.string.settings_playback_hold_to_speed),
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 22e877bb..68eeca73 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
@@ -38,6 +38,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Refresh
@@ -84,6 +85,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
+import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
import kotlin.math.round
@@ -114,10 +116,20 @@ fun StreamsScreen(
manualSelection: Boolean = false,
startFromBeginning: Boolean = false,
onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit = { _, _, _ -> },
+ onStreamActionOpen: (
+ stream: StreamItem,
+ openExternally: Boolean,
+ resumePositionMs: Long?,
+ resumeProgressFraction: Float?,
+ ) -> Unit = { _, _, _, _ -> },
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
+ val playerSettings by remember {
+ PlayerSettingsRepository.ensureLoaded()
+ PlayerSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
@@ -323,6 +335,7 @@ fun StreamsScreen(
StreamActionsSheet(
stream = streamActionsTarget,
+ externalPlayerEnabled = playerSettings.externalPlayerEnabled,
onDismiss = { streamActionsTarget = null },
onCopyLink = { stream ->
val directUrl = stream.directPlaybackUrl
@@ -351,6 +364,14 @@ fun StreamsScreen(
)
NuvioToastController.show(result.toastMessage())
},
+ onOpen = { stream, openExternally ->
+ onStreamActionOpen(
+ stream,
+ openExternally,
+ effectiveResumePositionMs,
+ effectiveResumeProgressFraction,
+ )
+ },
)
}
}
@@ -1008,9 +1029,11 @@ private fun StreamCard(
@Composable
private fun StreamActionsSheet(
stream: StreamItem?,
+ externalPlayerEnabled: Boolean,
onDismiss: () -> Unit,
onCopyLink: (StreamItem) -> Unit,
onDownload: (StreamItem) -> Unit,
+ onOpen: (StreamItem, openExternally: Boolean) -> Unit,
) {
if (stream == null) return
@@ -1069,6 +1092,23 @@ private fun StreamActionsSheet(
},
)
NuvioBottomSheetDivider()
+ NuvioBottomSheetActionRow(
+ icon = Icons.AutoMirrored.Rounded.OpenInNew,
+ title = stringResource(
+ if (externalPlayerEnabled) {
+ Res.string.streams_open_internal_player
+ } else {
+ Res.string.streams_open_external_player
+ },
+ ),
+ onClick = {
+ onOpen(stream, !externalPlayerEnabled)
+ coroutineScope.launch {
+ dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
+ }
+ },
+ )
+ NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Rounded.Download,
title = stringResource(Res.string.streams_download_file),
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt
new file mode 100644
index 00000000..738848c6
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt
@@ -0,0 +1,112 @@
+package com.nuvio.app.features.player
+
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+
+private data class IosExternalPlayerSpec(
+ val id: String,
+ val name: String,
+ val scheme: String,
+ val buildUrl: (ExternalPlayerPlaybackRequest) -> String,
+)
+
+private val iosExternalPlayerSpecs = listOf(
+ IosExternalPlayerSpec(
+ id = "infuse",
+ name = "Infuse",
+ scheme = "infuse",
+ buildUrl = { request ->
+ buildString {
+ append("infuse://x-callback-url/play?url=")
+ append(request.sourceUrl.urlQueryEncode())
+ append("&filename=")
+ append((request.streamTitle ?: request.title).urlQueryEncode())
+ }
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "vlc",
+ name = "VLC",
+ scheme = "vlc-x-callback",
+ buildUrl = { request ->
+ "vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}"
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "outplayer",
+ name = "Outplayer",
+ scheme = "outplayer",
+ buildUrl = { request ->
+ buildString {
+ append("outplayer://x-callback-url/play?url=")
+ append(request.sourceUrl.urlQueryEncode())
+ append("&filename=")
+ append((request.streamTitle ?: request.title).urlQueryEncode())
+ }
+ },
+ ),
+ IosExternalPlayerSpec(
+ id = "vidhub",
+ name = "VidHub",
+ scheme = "open-vidhub",
+ buildUrl = { request ->
+ "open-vidhub://x-callback-url/open?url=${request.sourceUrl.urlQueryEncode()}"
+ },
+ ),
+)
+
+internal actual object ExternalPlayerPlatform {
+ actual fun defaultPlayerId(): String? = null
+
+ actual fun availablePlayers(): List =
+ iosExternalPlayerSpecs
+ .filter { spec -> UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl()) }
+ .map { spec -> ExternalPlayerApp(spec.id, spec.name) }
+
+ actual fun open(
+ request: ExternalPlayerPlaybackRequest,
+ playerId: String?,
+ ): ExternalPlayerOpenResult {
+ if (playerId.isNullOrBlank()) return ExternalPlayerOpenResult.NotConfigured
+ val spec = iosExternalPlayerSpecs.firstOrNull { it.id == playerId }
+ ?: return ExternalPlayerOpenResult.NotConfigured
+ if (!UIApplication.sharedApplication.canOpenURL(spec.schemeProbeUrl())) {
+ return ExternalPlayerOpenResult.NoPlayerAvailable
+ }
+ val url = NSURL.URLWithString(spec.buildUrl(request))
+ ?: return ExternalPlayerOpenResult.Failed
+ UIApplication.sharedApplication.openURL(
+ url = url,
+ options = emptyMap(),
+ completionHandler = null,
+ )
+ return ExternalPlayerOpenResult.Opened
+ }
+}
+
+private fun IosExternalPlayerSpec.schemeProbeUrl(): NSURL =
+ NSURL.URLWithString("$scheme://") ?: NSURL.URLWithString("nuvio://")!!
+
+private fun String.urlQueryEncode(): String {
+ val hex = "0123456789ABCDEF"
+ return buildString {
+ encodeToByteArray().forEach { byte ->
+ val value = byte.toInt() and 0xFF
+ val char = value.toChar()
+ val safe = char in 'A'..'Z' ||
+ char in 'a'..'z' ||
+ char in '0'..'9' ||
+ char == '-' ||
+ char == '_' ||
+ char == '.' ||
+ char == '~'
+ if (safe) {
+ append(char)
+ } else {
+ append('%')
+ append(hex[value ushr 4])
+ append(hex[value and 0x0F])
+ }
+ }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
index 3f63f5db..0aedbb30 100644
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt
@@ -21,6 +21,8 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value"
+ private const val externalPlayerEnabledKey = "external_player_enabled"
+ private const val externalPlayerIdKey = "external_player_id"
private const val preferredAudioLanguageKey = "preferred_audio_language"
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
@@ -57,6 +59,8 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
+ externalPlayerEnabledKey,
+ externalPlayerIdKey,
preferredAudioLanguageKey,
secondaryPreferredAudioLanguageKey,
preferredSubtitleLanguageKey,
@@ -140,6 +144,36 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey))
}
+ actual fun loadExternalPlayerEnabled(): Boolean? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerEnabledKey)
+ return if (defaults.objectForKey(key) != null) {
+ defaults.boolForKey(key)
+ } else {
+ null
+ }
+ }
+
+ actual fun saveExternalPlayerEnabled(enabled: Boolean) {
+ NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(externalPlayerEnabledKey))
+ }
+
+ actual fun loadExternalPlayerId(): String? {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ return defaults.stringForKey(key)
+ }
+
+ actual fun saveExternalPlayerId(playerId: String?) {
+ val defaults = NSUserDefaults.standardUserDefaults
+ val key = ProfileScopedKey.of(externalPlayerIdKey)
+ if (playerId.isNullOrBlank()) {
+ defaults.removeObjectForKey(key)
+ } else {
+ defaults.setObject(playerId, forKey = key)
+ }
+ }
+
actual fun loadPreferredAudioLanguage(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(preferredAudioLanguageKey)
@@ -523,6 +557,8 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
+ loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
+ loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
@@ -563,6 +599,8 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
+ payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
+ payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d260a9e5..597dd1f1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -13,7 +13,7 @@ androidx-lifecycle = "2.11.0-alpha03"
androidx-work = "2.10.3"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.11.0-beta03"
-coil = "3.4.0"
+coil = "3.5.0-beta01"
kermit = "2.0.5"
junit = "4.13.2"
kotlin = "2.3.0"
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 9b4b9d6b..0e642b56 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=58
-MARKETING_VERSION=0.1.0
+CURRENT_PROJECT_VERSION=59
+MARKETING_VERSION=0.1.19
diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist
index 7ecac2c5..440a17c4 100644
--- a/iosApp/iosApp/Info.plist
+++ b/iosApp/iosApp/Info.plist
@@ -22,6 +22,13 @@
NSAllowsArbitraryLoads
+ LSApplicationQueriesSchemes
+
+ infuse
+ vlc-x-callback
+ outplayer
+ open-vidhub
+
NSSupportsLiveActivities