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