diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 000000000..e9dd9a557 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,193 @@ +name: Build Android Release + +on: + workflow_dispatch: + inputs: + mode: + description: Build only, create a draft, or publish the release + required: true + type: choice + options: + - dry-run + - draft + - publish + default: dry-run + exclude_commits: + description: Optional comma-separated commit hashes to omit from the notes + required: false + type: string + +permissions: + contents: write + +concurrency: + group: android-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: ${{ inputs.mode }} Android release + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_PROPERTIES_BASE64: ${{ secrets.NUVIO_LOCAL_PROPERTIES_BASE64 }} + RELEASE_KEYSTORE_BASE64: ${{ secrets.NUVIO_RELEASE_KEYSTORE_BASE64 }} + + steps: + - name: Check out full history + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Read release metadata + id: release + run: ./scripts/release-metadata.sh "${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" + + - name: Validate release state + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + CURRENT_BUMP: ${{ steps.release.outputs.current_bump }} + RELEASE_COMMIT: ${{ steps.release.outputs.release_commit }} + RELEASE_BRANCH: ${{ github.ref_name }} + RELEASE_REF_TYPE: ${{ github.ref_type }} + RELEASE_MODE: ${{ inputs.mode }} + run: | + if [[ "${RELEASE_REF_TYPE}" != "branch" ]]; then + echo "Releases must be dispatched from a branch, not ${RELEASE_REF_TYPE}." >&2 + exit 1 + fi + + if git rev-parse --verify --quiet "refs/tags/${RELEASE_TAG}"; then + echo "Tag ${RELEASE_TAG} already exists." >&2 + exit 1 + fi + if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" >/dev/null 2>&1; then + echo "A GitHub release for ${RELEASE_TAG} already exists." >&2 + exit 1 + fi + + unexpected_changes="$( + git diff --name-only "${CURRENT_BUMP}..${RELEASE_COMMIT}" \ + | grep -Ev '^(\.github/workflows/android-release\.yml|scripts/(generate-release-notes|release-metadata)\.sh)$' \ + || true + )" + if [[ -n "${unexpected_changes}" ]]; then + echo "The version bump must be the final application change before release." >&2 + echo "Unexpected files changed after the bump:" >&2 + echo "${unexpected_changes}" >&2 + exit 1 + fi + + if [[ "${RELEASE_MODE}" != "dry-run" ]]; then + required_secrets=( + RELEASE_PROPERTIES_BASE64 + RELEASE_KEYSTORE_BASE64 + ) + missing=() + for secret_name in "${required_secrets[@]}"; do + if [[ -z "${!secret_name}" ]]; then + missing+=("${secret_name}") + fi + done + if (( ${#missing[@]} > 0 )); then + printf 'Missing required release secrets: %s\n' "${missing[*]}" >&2 + exit 1 + fi + fi + + - name: Generate release notes + env: + PREVIOUS_BUMP: ${{ steps.release.outputs.previous_bump }} + CURRENT_BUMP: ${{ steps.release.outputs.current_bump }} + EXCLUDE_COMMITS: ${{ inputs.exclude_commits }} + run: | + ./scripts/generate-release-notes.sh \ + --from "${PREVIOUS_BUMP}" \ + --to "${CURRENT_BUMP}" \ + --repository "${GITHUB_REPOSITORY}" \ + --exclude "${EXCLUDE_COMMITS}" \ + > release-notes.md + + if [[ ! -s release-notes.md ]]; then + echo "No release-note commits were found." >&2 + exit 1 + fi + + { + echo "## ${{ steps.release.outputs.version }} release notes" + echo + cat release-notes.md + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Set up Java + if: inputs.mode != 'dry-run' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Set up Gradle + if: inputs.mode != 'dry-run' + uses: gradle/actions/setup-gradle@v6 + + - name: Configure release properties + if: inputs.mode != 'dry-run' + run: | + keystore_path="${RUNNER_TEMP}/nuvio-release.jks" + printf '%s' "${RELEASE_KEYSTORE_BASE64}" | base64 --decode > "${keystore_path}" + printf '%s' "${RELEASE_PROPERTIES_BASE64}" | base64 --decode > local.properties + + # These paths only apply to the machine where local.properties was encoded. + sed -i -E '/^[[:space:]]*(sdk\.dir|NUVIO_RELEASE_STORE_FILE)[[:space:]]*=/d' local.properties + printf '\nNUVIO_RELEASE_STORE_FILE=%s\n' "${keystore_path}" >> local.properties + + required_properties=( + NUVIO_RELEASE_STORE_PASSWORD + NUVIO_RELEASE_KEY_ALIAS + NUVIO_RELEASE_KEY_PASSWORD + TRAKT_CLIENT_ID + TRAKT_CLIENT_SECRET + ) + for property_name in "${required_properties[@]}"; do + if ! grep -Eq "^${property_name}=.+" local.properties; then + echo "Missing required property: ${property_name}" >&2 + exit 1 + fi + done + + - name: Build full release APKs + if: inputs.mode != 'dry-run' + run: ./gradlew :androidApp:assembleFullRelease --stacktrace + + - name: Upload build artifacts + if: inputs.mode != 'dry-run' + uses: actions/upload-artifact@v6 + with: + name: nuvio-${{ steps.release.outputs.version }}-full-release + path: androidApp/build/outputs/apk/full/release/*.apk + if-no-files-found: error + + - name: Create GitHub release + if: inputs.mode != 'dry-run' + env: + RELEASE_MODE: ${{ inputs.mode }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_BRANCH: ${{ github.ref_name }} + run: | + mapfile -t release_apks < <(find androidApp/build/outputs/apk/full/release -maxdepth 1 -type f -name '*.apk' -print | sort) + if (( ${#release_apks[@]} == 0 )); then + echo "No release APKs were produced." >&2 + exit 1 + fi + + release_args=( + "${RELEASE_TAG}" + --target "${RELEASE_BRANCH}" + --title "${RELEASE_VERSION}" + --notes-file release-notes.md + ) + if [[ "${RELEASE_MODE}" == "draft" ]]; then + release_args+=(--draft) + fi + gh release create "${release_args[@]}" "${release_apks[@]}" diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 02d3a58db..b1b36fd33 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -226,6 +226,7 @@ val iosDistributionSourceDir = if (iosDistribution == "full") { "src/iosAppStore/kotlin" } val iosFrameworkBundleId = "com.nuvio.media" +val nuvioEngineAppleFramework = rootProject.file("../nuvio-engine/platform/apple/NuvioEngine.xcframework") val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin") val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin") val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName -> @@ -329,12 +330,28 @@ kotlin { ) iosTargets.forEach { iosTarget -> + val nuvioEngineSlice = if (iosTarget.name == "iosArm64") { + "ios-arm64" + } else { + "ios-arm64_x86_64-simulator" + } + val nuvioEngineSliceDirectory = nuvioEngineAppleFramework.resolve(nuvioEngineSlice) iosTarget.compilations.getByName("main") { cinterops { create("commoncrypto") { defFile(project.file("src/nativeInterop/cinterop/commoncrypto.def")) compilerOpts("-I${project.projectDir}/src/nativeInterop/cinterop") } + if (iosDistribution == "full") { + check(nuvioEngineSliceDirectory.resolve("libCNuvioEngine.a").isFile) { + "Build the local Nuvio Engine Apple XCFramework before compiling iOS Full." + } + create("nuvioengine") { + defFile(project.file("src/nativeInterop/cinterop/nuvioengine.def")) + compilerOpts("-I${nuvioEngineSliceDirectory.resolve("Headers").absolutePath}") + extraOpts("-libraryPath", nuvioEngineSliceDirectory.absolutePath) + } + } } if (iosDistribution == "full") { @@ -354,6 +371,14 @@ kotlin { baseName = "ComposeApp" isStatic = true freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId") + if (iosDistribution == "full") { + linkerOpts( + "-lc++", + "-framework", "Security", + "-framework", "SystemConfiguration", + "-framework", "CoreFoundation", + ) + } } } @@ -392,6 +417,7 @@ kotlin { implementation(libs.androidx.media3.container) implementation(libs.androidx.media3.extractor) implementation(libs.mpv.android.lib) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1") implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar")))) if (androidDistribution == "full") { implementation(files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) diff --git a/composeApp/libs/lib-nuvio-engine-android-0.1.0.aar b/composeApp/libs/lib-nuvio-engine-android-0.1.0.aar new file mode 100644 index 000000000..16dbe39b8 Binary files /dev/null and b/composeApp/libs/lib-nuvio-engine-android-0.1.0.aar differ diff --git a/composeApp/proguard-rules.pro b/composeApp/proguard-rules.pro index 49a3ed451..e06a3b996 100644 --- a/composeApp/proguard-rules.pro +++ b/composeApp/proguard-rules.pro @@ -39,8 +39,11 @@ -keep class com.dokar.quickjs.** { *; } -keep class com.nuvio.app.features.plugins.** { *; } -# TorrServer based P2P streaming. +# P2P runtime and Nuvio Engine JNI bridge. Native libraries are not processed +# by R8, but their Kotlin/JNI wrapper classes and method names must stay stable. -keep class com.nuvio.app.features.p2p.** { *; } +-keep class com.nuvio.engine.** { *; } +-keep interface com.nuvio.engine.** { *; } -keep class androidx.work.impl.WorkDatabase_Impl { *; } diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index c39674bd6..c488bb4bb 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -11,12 +11,16 @@ internal object PlatformPlaybackDataSourceFactory { defaultRequestHeaders: Map, defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, + useLongReadTimeout: Boolean = false, externalSubtitles: List = emptyList(), ): DataSource.Factory { val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) { YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders) } else { - PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders) + PlayerPlaybackNetworking.createHttpDataSourceFactory( + defaultRequestHeaders, + useLongReadTimeout, + ) } val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory( upstreamFactory = networkFactory, @@ -32,4 +36,4 @@ internal object PlatformPlaybackDataSourceFactory { ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngineAndroidTest.kt b/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngineAndroidTest.kt new file mode 100644 index 000000000..393e18eaa --- /dev/null +++ b/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngineAndroidTest.kt @@ -0,0 +1,92 @@ +package com.nuvio.app.features.p2p + +import com.nuvio.engine.NuvioUploadMode +import com.nuvio.engine.NuvioTorrentProfile +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class P2pStreamingEngineAndroidTest { + @Test + fun appOwnedRoutesDisableAutomaticInactivityExpiry() { + val stateDirectory = File("state") + val cacheDirectory = File("cache") + + val uploading = buildNuvioEngineConfig( + stateDirectory = stateDirectory, + cacheDirectory = cacheDirectory, + uploadEnabled = true, + torrentProfile = P2pTorrentProfile.FAST, + diskCacheCapacityBytes = P2pCacheSize.GB_5.bytes, + ) + val downloadOnly = buildNuvioEngineConfig( + stateDirectory = stateDirectory, + cacheDirectory = cacheDirectory, + uploadEnabled = false, + torrentProfile = P2pTorrentProfile.SOFT, + diskCacheCapacityBytes = P2pCacheSize.NONE.bytes, + ) + + assertEquals(0, uploading.streamInactivityTimeoutMilliseconds) + assertEquals(NuvioUploadMode.Unlimited, uploading.uploadMode) + assertEquals(NuvioTorrentProfile.Fast, uploading.torrentProfile) + assertEquals(P2pCacheSize.GB_5.bytes, uploading.diskCacheCapacityBytes) + assertEquals(0, downloadOnly.streamInactivityTimeoutMilliseconds) + assertEquals(NuvioUploadMode.Disabled, downloadOnly.uploadMode) + assertEquals(NuvioTorrentProfile.Soft, downloadOnly.torrentProfile) + assertEquals(0L, downloadOnly.diskCacheCapacityBytes) + } + + @Test + fun matchingUnsolicitedStopBecomesTerminalError() { + val error = unexpectedStreamStopError( + requestId = 0L, + eventStreamId = "stream", + currentStreamId = "stream", + message = "stream expired after inactivity", + fallbackMessage = "unknown", + ) + + assertEquals( + P2pStreamingState.Error("stream expired after inactivity"), + error, + ) + } + + @Test + fun explicitAndStaleStopsAreIgnored() { + assertNull( + unexpectedStreamStopError( + requestId = 7L, + eventStreamId = "stream", + currentStreamId = "stream", + message = "stopped", + fallbackMessage = "unknown", + ) + ) + assertNull( + unexpectedStreamStopError( + requestId = 0L, + eventStreamId = "old-stream", + currentStreamId = "stream", + message = "stopped", + fallbackMessage = "unknown", + ) + ) + } + + @Test + fun blankStopMessageUsesFallback() { + assertEquals( + P2pStreamingState.Error("unknown"), + unexpectedStreamStopError( + requestId = 0L, + eventStreamId = "stream", + currentStreamId = "stream", + message = " ", + fallbackMessage = "unknown", + ), + ) + } +} diff --git a/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so b/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so deleted file mode 100755 index 12994d13e..000000000 Binary files a/composeApp/src/androidMain/jniLibs/arm64-v8a/libtorrserver.so and /dev/null differ diff --git a/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so b/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so deleted file mode 100755 index 6e7bc6bcb..000000000 Binary files a/composeApp/src/androidMain/jniLibs/armeabi-v7a/libtorrserver.so and /dev/null differ diff --git a/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so b/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so deleted file mode 100755 index 6309221c4..000000000 Binary files a/composeApp/src/androidMain/jniLibs/x86/libtorrserver.so and /dev/null differ diff --git a/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so b/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so deleted file mode 100755 index 039eba7d5..000000000 Binary files a/composeApp/src/androidMain/jniLibs/x86_64/libtorrserver.so and /dev/null differ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index da28ad7e0..d13a24530 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -21,6 +21,7 @@ import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage +import com.nuvio.app.features.library.LibraryDisplaySettingsStorage import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage @@ -103,6 +104,7 @@ class MainActivity : AppCompatActivity() { TraktCommentsStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext) TraktSettingsStorage.initialize(applicationContext) + LibraryDisplaySettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index b6d09cbdd..4817bce80 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val preferenceNames = listOf( "nuvio_addons", "nuvio_library", + "nuvio_library_display_settings", "nuvio_home_catalog_settings", "nuvio_player_settings", "torrent_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt new file mode 100644 index 000000000..75b026835 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.library + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object LibraryDisplaySettingsStorage { + private const val preferencesName = "nuvio_library_display_settings" + private const val payloadKey = "library_display_settings_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt index 45e63d432..2d77ab611 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.android.kt @@ -22,6 +22,7 @@ actual object MdbListSettingsStorage { private const val useTraktKey = "mdblist_use_trakt" private const val useLetterboxdKey = "mdblist_use_letterboxd" private const val useAudienceKey = "mdblist_use_audience" + private const val useMalKey = "mdblist_use_mal" private val syncKeys = listOf( enabledKey, apiKey, @@ -32,6 +33,7 @@ actual object MdbListSettingsStorage { useTraktKey, useLetterboxdKey, useAudienceKey, + useMalKey, ) private var preferences: SharedPreferences? = null @@ -98,6 +100,12 @@ actual object MdbListSettingsStorage { saveBoolean(useAudienceKey, enabled) } + actual fun loadUseMal(): Boolean? = loadBoolean(useMalKey) + + actual fun saveUseMal(enabled: Boolean) { + saveBoolean(useMalKey, enabled) + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) @@ -125,6 +133,7 @@ actual object MdbListSettingsStorage { loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } + loadUseMal()?.let { put(useMalKey, encodeSyncBoolean(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -141,5 +150,6 @@ actual object MdbListSettingsStorage { payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) + payload.decodeSyncBoolean(useMalKey)?.let(::saveUseMal) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.android.kt index 0e3a39a71..7f83e2b33 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.android.kt @@ -9,6 +9,8 @@ internal actual object P2pSettingsStorage { private const val p2pEnabledKey = "p2p_enabled" private const val enableUploadKey = "enable_upload" private const val hideTorrentStatsKey = "hide_torrent_stats" + private const val torrentProfileKey = "torrent_profile" + private const val cacheSizeKey = "cache_size" private var preferences: SharedPreferences? = null @@ -37,6 +39,18 @@ internal actual object P2pSettingsStorage { saveBoolean(hideTorrentStatsKey, enabled) } + actual fun loadTorrentProfile(): String? = loadString(torrentProfileKey) + + actual fun saveTorrentProfile(profile: String) { + saveString(torrentProfileKey, profile) + } + + actual fun loadCacheSize(): String? = loadString(cacheSizeKey) + + actual fun saveCacheSize(size: String) { + saveString(cacheSizeKey, size) + } + private fun loadBoolean(keyBase: String): Boolean? = preferences?.let { sharedPreferences -> val key = ProfileScopedKey.of(keyBase) @@ -53,4 +67,14 @@ internal actual object P2pSettingsStorage { ?.putBoolean(ProfileScopedKey.of(keyBase), value) ?.apply() } + + private fun loadString(keyBase: String): String? = + preferences?.getString(ProfileScopedKey.of(keyBase), null) + + private fun saveString(keyBase: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(keyBase), value) + ?.apply() + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt index 93aecad7c..b7523e666 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt @@ -1,172 +1,775 @@ package com.nuvio.app.features.p2p import android.content.Context +import android.os.SystemClock import android.util.Log import com.nuvio.app.core.i18n.localizedP2pUnknownTorrentError +import com.nuvio.engine.NuvioEngine +import com.nuvio.engine.NuvioEngineConfig +import com.nuvio.engine.NuvioEventType +import com.nuvio.engine.NuvioStream +import com.nuvio.engine.NuvioTorrentProfile +import com.nuvio.engine.NuvioUploadMode +import java.io.File +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONArray -import org.json.JSONObject -import java.io.File -import java.net.URLEncoder -import java.util.concurrent.TimeUnit private const val TAG = "P2pStreamingEngine" -private val VIDEO_EXTENSIONS = setOf("mkv", "mp4", "avi", "webm", "ts", "m4v", "mov", "wmv", "flv") +private const val DIAGNOSTIC_TAG = "NuvioP2PDiag" +private const val DIAGNOSTIC_SAMPLE_INTERVAL_MS = 1_000L + +internal fun buildNuvioEngineConfig( + stateDirectory: File, + cacheDirectory: File, + uploadEnabled: Boolean, + torrentProfile: P2pTorrentProfile, + diskCacheCapacityBytes: Long, +): NuvioEngineConfig = NuvioEngineConfig( + dataDirectory = stateDirectory, + cacheDirectory = cacheDirectory, + diskCacheCapacityBytes = diskCacheCapacityBytes, + torrentProfile = when (torrentProfile) { + P2pTorrentProfile.SOFT -> NuvioTorrentProfile.Soft + P2pTorrentProfile.BALANCED -> NuvioTorrentProfile.Balanced + P2pTorrentProfile.FAST -> NuvioTorrentProfile.Fast + }, + uploadMode = if (uploadEnabled) { + NuvioUploadMode.Unlimited + } else { + NuvioUploadMode.Disabled + }, + streamInactivityTimeoutMilliseconds = 0, +) + +internal fun unexpectedStreamStopError( + requestId: Long, + eventStreamId: String?, + currentStreamId: String?, + message: String?, + fallbackMessage: String, +): P2pStreamingState.Error? { + if (requestId != 0L || currentStreamId == null || eventStreamId != currentStreamId) { + return null + } + return P2pStreamingState.Error( + message?.trim()?.takeIf(String::isNotEmpty) ?: fallbackMessage, + ) +} actual object P2pStreamingEngine { + private data class EngineConfigurationKey( + val uploadEnabled: Boolean, + val torrentProfile: P2pTorrentProfile, + val diskCacheCapacityBytes: Long, + ) + + private data class DetachedStream( + val engine: NuvioEngine?, + val streamId: String?, + ) + private val _state = MutableStateFlow(P2pStreamingState.Idle) actual val state: StateFlow = _state.asStateFlow() + private val _cacheState = MutableStateFlow(P2pCacheUiState()) + actual val cacheState: StateFlow = _cacheState.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val lifecycleLock = Any() + private val startMutex = Mutex() private var statsJob: Job? = null private var cleanupJob: Job? = null - private var currentHash: String? = null + private var engineEventsJob: Job? = null private var streamGeneration = 0L + @Volatile + private var currentTorrentId: String? = null + @Volatile + private var currentStreamId: String? = null private var appContext: Context? = null - private val binary = TorrServerBinary() - private val api = TorrServerApi(binary) + @Volatile + private var engine: NuvioEngine? = null + private var engineConfigurationKey: EngineConfigurationKey? = null + private val knownTorrentIds = mutableSetOf() + private var diagnosticRequestSequence = 0L fun initialize(context: Context) { appContext = context.applicationContext - binary.initialize(context.applicationContext) } actual suspend fun startStream(request: P2pStreamRequest): String = withContext(Dispatchers.IO) { - stopStreamNow(stopBinary = false) - val generation = nextStreamGeneration() - _state.value = P2pStreamingState.Connecting + startMutex.withLock { startStreamLocked(request) } + } - try { - binary.start() + actual suspend fun clearCache(): P2pCacheClearResult = withContext(Dispatchers.IO) { + startMutex.withLock { + check(_state.value !is P2pStreamingState.Streaming && + _state.value !is P2pStreamingState.Connecting) { + "Torrent cache cannot be cleared during active playback" + } + _cacheState.value = _cacheState.value.copy(isClearing = true) + try { + val activeEngine = ensureEngine() + if (!_cacheState.value.hasMeasurement) { + delay(DIAGNOSTIC_SAMPLE_INTERVAL_MS + 100L) + val initial = activeEngine.stats.value + updateCacheState( + initial.diskCacheUsedBytes, + initial.diskCacheProtectedBytes, + ) + } + val before = activeEngine.stats.value + activeEngine.reclaimDiskCache(0L) + delay(DIAGNOSTIC_SAMPLE_INTERVAL_MS + 100L) + val after = activeEngine.stats.value + updateCacheState(after.diskCacheUsedBytes, after.diskCacheProtectedBytes) + P2pCacheClearResult( + reclaimedBytes = (before.diskCacheUsedBytes - after.diskCacheUsedBytes) + .coerceAtLeast(0L), + remainingBytes = after.diskCacheUsedBytes, + protectedBytes = after.diskCacheProtectedBytes, + ) + } finally { + _cacheState.value = _cacheState.value.copy(isClearing = false) + } + } + } + + private suspend fun startStreamLocked(request: P2pStreamRequest): String { + val requestSequence = nextDiagnosticRequestSequence() + val startedAtMs = SystemClock.elapsedRealtime() + val phase = AtomicReference("stop_previous") + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=accepted hash=${diagnosticId(request.infoHash)} " + + "fileIndex=${request.fileIdx ?: -1} filenameHint=${!request.filename.isNullOrBlank()} " + + "requestTrackers=${request.trackers.size}", + ) + stopStreamNow(shutdownEngine = false) + logPhase(requestSequence, startedAtMs, phase.get()) + val generation = beginStreamGeneration() + + var activeEngine: NuvioEngine? = null + var payloadDownloadBaseline = 0L + var preparedStream: NuvioStream? = null + var attached = false + var startupStatsJob: Job? = null + return try { + phase.set("build_magnet") + val magnetUri = buildP2pMagnetUri( + request.infoHash, + (DEFAULT_TRACKERS + request.trackers).distinct(), + ) + logPhase(requestSequence, startedAtMs, phase.get()) + + phase.set("ensure_engine") + val resolvedEngine = ensureEngine() + activeEngine = resolvedEngine + payloadDownloadBaseline = resolvedEngine.stats.value.totalPayloadDownloadBytes ensureCurrentGeneration(generation) + logPhase(requestSequence, startedAtMs, phase.get()) + startupStatsJob = startStartupStatsPolling( + activeEngine = resolvedEngine, + generation = generation, + phase = phase, + requestSequence = requestSequence, + startedAtMs = startedAtMs, + ) - val magnetLink = buildMagnetUri(request.infoHash, request.trackers) - Log.d(TAG, "Starting stream: $magnetLink") + phase.set("add_magnet") + val canonicalHash = canonicalP2pInfoHash(request.infoHash) + val reusedTorrent = canonicalHash in knownTorrentIds + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} begin elapsedMs=${elapsedSince(startedAtMs)} " + + "cached=$reusedTorrent hash=${diagnosticId(canonicalHash)}", + ) + val torrentId = if (reusedTorrent) { + canonicalHash + } else { + resolvedEngine.addMagnet(magnetUri).also { knownTorrentIds += it } + } + ensureCurrentGeneration(generation) + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} complete elapsedMs=${elapsedSince(startedAtMs)} " + + "torrent=${diagnosticId(torrentId)} cached=$reusedTorrent", + ) - val hash = api.addTorrent(magnetLink) - ?: throw P2pStreamingException("Failed to add torrent") - if (!attachTorrentIfCurrent(generation, hash)) { - api.dropTorrent(hash) + phase.set("prepare_stream") + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} begin elapsedMs=${elapsedSince(startedAtMs)} " + + "fileIndex=${request.fileIdx ?: -1}", + ) + val stream = resolvedEngine.prepareStream( + torrentId = torrentId, + fileIndex = request.fileIdx, + filenameHint = request.filename, + ) + preparedStream = stream + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} complete elapsedMs=${elapsedSince(startedAtMs)} " + + "stream=${diagnosticId(stream.id)} selectedFile=${stream.fileIndex} fileBytes=${stream.fileSize} " + + "url=${diagnosticLoopbackUrl(stream.url)}", + ) + currentCoroutineContext().ensureActive() + phase.set("attach_route") + if (!attachStreamIfCurrent(generation, torrentId, stream.id)) { + withContext(NonCancellable) { + stopPreparedStream(resolvedEngine, stream.id) + } + preparedStream = null throw CancellationException("P2P stream start was cancelled") } + attached = true + logPhase(requestSequence, startedAtMs, phase.get()) - val resolvedIdx = resolveFileIndex( - hash = hash, - requestedIdx = request.fileIdx, - filename = request.filename, + startStatsPolling( + activeEngine = resolvedEngine, + stream = stream, + generation = generation, + requestSequence = requestSequence, + startedAtMs = startedAtMs, + payloadDownloadBaseline = payloadDownloadBaseline, ) - ensureCurrentGeneration(generation) - - val streamUrl = api.getStreamUrl(magnetLink, resolvedIdx) - Log.d(TAG, "Stream URL: $streamUrl") - - startStatsPolling(hash, generation) - - ensureCurrentGeneration(generation) - _state.value = P2pStreamingState.Streaming( - localUrl = streamUrl, - downloadSpeed = 0, - uploadSpeed = 0, - peers = 0, - seeds = 0, - bufferProgress = 0f, - totalProgress = 0f, - ) - - streamUrl - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (isCurrentGeneration(generation)) { - _state.value = P2pStreamingState.Error(e.message ?: localizedP2pUnknownTorrentError()) + val initialAggregate = resolvedEngine.stats.value + if (!publishStreamingIfCurrent( + generation = generation, + state = P2pStreamingState.Streaming( + localUrl = stream.url, + downloadSpeed = initialAggregate.downloadRateBytesPerSecond, + uploadSpeed = initialAggregate.uploadRateBytesPerSecond, + peers = initialAggregate.connectedPeers, + seeds = initialAggregate.connectedSeeds, + bufferProgress = 0f, + totalProgress = 0f, + downloadedBytes = (initialAggregate.totalPayloadDownloadBytes - + payloadDownloadBaseline).coerceAtLeast(0L), + ), + )) { + throw CancellationException("P2P stream start was cancelled") } - throw e + Log.i(TAG, "Nuvio Engine stream ready: ${stream.url}") + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=route_ready elapsedMs=${elapsedSince(startedAtMs)} " + + "generation=$generation stream=${diagnosticId(stream.id)}", + ) + stream.url + } catch (cancellation: CancellationException) { + Log.w( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} cancelled elapsedMs=${elapsedSince(startedAtMs)} " + + "generation=$generation", + ) + withContext(NonCancellable) { + cleanupFailedStart( + generation = generation, + activeEngine = activeEngine, + preparedStream = preparedStream, + attached = attached, + terminalState = P2pStreamingState.Idle, + ) + } + throw cancellation + } catch (error: Exception) { + Log.e( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=${phase.get()} failed elapsedMs=${elapsedSince(startedAtMs)} " + + "generation=$generation error=${diagnosticMessage(error.message)}", + error, + ) + val terminalState = P2pStreamingState.Error( + error.message ?: localizedP2pUnknownTorrentError() + ) + withContext(NonCancellable) { + cleanupFailedStart( + generation = generation, + activeEngine = activeEngine, + preparedStream = preparedStream, + attached = attached, + terminalState = terminalState, + ) + } + throw error + } finally { + startupStatsJob?.cancel() } } actual fun stopStream() { - scheduleStop(stopBinary = false) + scheduleStop(shutdownEngine = false) } actual fun shutdown() { - scheduleStop(stopBinary = true) + scheduleStop(shutdownEngine = true) } - private fun scheduleStop(stopBinary: Boolean) { - val hash = detachActiveStream() + private fun scheduleStop(shutdownEngine: Boolean) { + val startedAtMs = SystemClock.elapsedRealtime() + val detached = detachActiveStream() + Log.i( + DIAGNOSTIC_TAG, + "cleanup scheduled shutdownEngine=$shutdownEngine stream=${diagnosticId(detached.streamId)}", + ) val previousCleanup = cleanupJob cleanupJob = scope.launch { previousCleanup?.join() - cleanupDetachedStream(hash, stopBinary) + cleanupDetachedStream(detached, shutdownEngine) + Log.i( + DIAGNOSTIC_TAG, + "cleanup complete shutdownEngine=$shutdownEngine stream=${diagnosticId(detached.streamId)} " + + "elapsedMs=${elapsedSince(startedAtMs)}", + ) } } - private suspend fun stopStreamNow(stopBinary: Boolean) { + private suspend fun stopStreamNow(shutdownEngine: Boolean) { + val startedAtMs = SystemClock.elapsedRealtime() cleanupJob?.join() - val hash = detachActiveStream() - cleanupDetachedStream(hash, stopBinary) + val detached = detachActiveStream() + cleanupDetachedStream(detached, shutdownEngine) + Log.i( + DIAGNOSTIC_TAG, + "cleanup synchronous shutdownEngine=$shutdownEngine stream=${diagnosticId(detached.streamId)} " + + "elapsedMs=${elapsedSince(startedAtMs)}", + ) } - private fun detachActiveStream(): String? { - val detached = synchronized(lifecycleLock) { + private fun detachActiveStream(): DetachedStream { + val detached: Pair = synchronized(lifecycleLock) { streamGeneration += 1 - val hash = currentHash + val value = DetachedStream(engine = engine, streamId = currentStreamId) val job = statsJob - currentHash = null + currentTorrentId = null + currentStreamId = null statsJob = null - hash to job + _state.value = P2pStreamingState.Idle + value to job } detached.second?.cancel() - _state.value = P2pStreamingState.Idle return detached.first } - private suspend fun cleanupDetachedStream(hash: String?, stopBinary: Boolean) { - hash?.let { - try { - api.dropTorrent(it) - } catch (e: Exception) { - Log.w(TAG, "Error dropping torrent", e) + private fun detachGenerationIfCurrent( + generation: Long, + terminalState: P2pStreamingState, + ): DetachedStream? { + val detached: Pair? = synchronized(lifecycleLock) { + if (streamGeneration != generation) return@synchronized null + streamGeneration += 1 + val value = DetachedStream(engine = engine, streamId = currentStreamId) + val job = statsJob + currentTorrentId = null + currentStreamId = null + statsJob = null + _state.value = terminalState + value to job + } + detached?.second?.cancel() + return detached?.first + } + + private suspend fun cleanupDetachedStream(detached: DetachedStream, shutdownEngine: Boolean) { + detached.streamId?.let { streamId -> + stopPreparedStream(detached.engine, streamId) + } + if (shutdownEngine) { + closeEngine(detached.engine) + } + } + + private suspend fun cleanupFailedStart( + generation: Long, + activeEngine: NuvioEngine?, + preparedStream: NuvioStream?, + attached: Boolean, + terminalState: P2pStreamingState, + ) { + if (attached) { + detachGenerationIfCurrent(generation, terminalState)?.let { detached -> + cleanupDetachedStream(detached, shutdownEngine = false) } + } else { + preparedStream?.let { stream -> stopPreparedStream(activeEngine, stream.id) } + detachGenerationIfCurrent(generation, terminalState) + } + } + + private suspend fun stopPreparedStream(activeEngine: NuvioEngine?, streamId: String) { + try { + activeEngine?.stopStream(streamId) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Log.w(TAG, "Error stopping Nuvio Engine stream route", error) + } + } + + private suspend fun ensureEngine(): NuvioEngine { + val startedAtMs = SystemClock.elapsedRealtime() + P2pSettingsRepository.ensureLoaded() + val settings = P2pSettingsRepository.uiState.value + val configurationKey = EngineConfigurationKey( + uploadEnabled = settings.enableUpload, + torrentProfile = settings.torrentProfile, + diskCacheCapacityBytes = settings.cacheSize.bytes, + ) + engine?.takeIf { engineConfigurationKey == configurationKey }?.let { + Log.i( + DIAGNOSTIC_TAG, + "engine reuse configuration=$configurationKey elapsedMs=${elapsedSince(startedAtMs)}", + ) + return it } - if (stopBinary) { + Log.i( + DIAGNOSTIC_TAG, + "engine create begin configuration=$configurationKey replacing=${engine != null}", + ) + closeEngine(engine) + currentCoroutineContext().ensureActive() + val context = requireContext() + val stateDirectory = File(context.noBackupFilesDir, "nuvio-engine/state") + val cacheDirectory = File(context.cacheDir, "nuvio-engine/payload") + check(stateDirectory.mkdirs() || stateDirectory.isDirectory) { + "Could not create the Nuvio Engine state directory" + } + check(cacheDirectory.mkdirs() || cacheDirectory.isDirectory) { + "Could not create the Nuvio Engine cache directory" + } + return NuvioEngine.create( + buildNuvioEngineConfig( + stateDirectory = stateDirectory, + cacheDirectory = cacheDirectory, + uploadEnabled = configurationKey.uploadEnabled, + torrentProfile = configurationKey.torrentProfile, + diskCacheCapacityBytes = configurationKey.diskCacheCapacityBytes, + ) + ).also { created -> + engine = created + engineConfigurationKey = configurationKey + observeEngineEvents(created) + Log.i( + TAG, + "Using Nuvio Engine ${NuvioEngine.version} (${NuvioEngine.protocolBackendVersion})" + ) + Log.i( + DIAGNOSTIC_TAG, + "engine create complete configuration=$configurationKey elapsedMs=${elapsedSince(startedAtMs)} " + + "version=${NuvioEngine.version} backend=${NuvioEngine.protocolBackendVersion}", + ) + } + } + + private suspend fun closeEngine(target: NuvioEngine?) { + if (target == null) return + val startedAtMs = SystemClock.elapsedRealtime() + Log.i(DIAGNOSTIC_TAG, "engine shutdown begin active=${engine === target}") + if (engine === target) { + engineEventsJob?.cancel() + engineEventsJob = null + engine = null + engineConfigurationKey = null + knownTorrentIds.clear() + } + withContext(NonCancellable) { try { - binary.stop() - } catch (e: Exception) { - Log.w(TAG, "Error stopping TorrServer", e) + target.shutdown() + } catch (error: Exception) { + Log.w(TAG, "Error shutting down Nuvio Engine", error) + } + } + Log.i( + DIAGNOSTIC_TAG, + "engine shutdown complete elapsedMs=${elapsedSince(startedAtMs)}", + ) + } + + private fun observeEngineEvents(activeEngine: NuvioEngine) { + engineEventsJob?.cancel() + engineEventsJob = scope.launch { + activeEngine.events.collect { event -> + Log.i( + DIAGNOSTIC_TAG, + "event type=${event.type} sequence=${event.sequence} requestId=${event.requestId} " + + "dropped=${event.droppedEvents} torrent=${diagnosticId(event.torrentId)} " + + "stream=${diagnosticId(event.streamId)} fileIndex=${event.fileIndex ?: -1} " + + "fileBytes=${event.fileSize} message=${diagnosticMessage(event.message)}", + ) + if (engine !== activeEngine) return@collect + when (event.type) { + NuvioEventType.TorrentError -> { + if (event.requestId != 0L) return@collect + val terminalError = P2pStreamingState.Error( + event.message ?: localizedP2pUnknownTorrentError() + ) + synchronized(lifecycleLock) { + if (engine !== activeEngine || + (event.torrentId != null && event.torrentId != currentTorrentId) + ) { + return@synchronized + } + streamGeneration += 1 + statsJob?.cancel() + statsJob = null + _state.value = terminalError + } + } + NuvioEventType.StreamStopped -> { + if (event.requestId != 0L) return@collect + val fallbackMessage = localizedP2pUnknownTorrentError() + synchronized(lifecycleLock) { + if (engine !== activeEngine) return@synchronized + val error = unexpectedStreamStopError( + requestId = event.requestId, + eventStreamId = event.streamId, + currentStreamId = currentStreamId, + message = event.message, + fallbackMessage = fallbackMessage, + ) ?: return@synchronized + streamGeneration += 1 + currentTorrentId = null + currentStreamId = null + statsJob?.cancel() + statsJob = null + _state.value = error + } + } + else -> Unit + } } } } - private fun nextStreamGeneration(): Long = - synchronized(lifecycleLock) { - streamGeneration += 1 - streamGeneration + private fun startStatsPolling( + activeEngine: NuvioEngine, + stream: NuvioStream, + generation: Long, + requestSequence: Long, + startedAtMs: Long, + payloadDownloadBaseline: Long, + ) { + statsJob?.cancel() + statsJob = scope.launch { + var nextDiagnosticSampleAtMs = 0L + while (isActive) { + if (!isCurrentGeneration(generation)) return@launch + val currentState = _state.value + if (currentState is P2pStreamingState.Streaming) { + val route = try { + activeEngine.currentStreamStats(stream.id) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Log.w(TAG, "Error sampling Nuvio Engine stream progress", error) + null + } + val aggregate = activeEngine.stats.value + updateCacheState( + aggregate.diskCacheUsedBytes, + aggregate.diskCacheProtectedBytes, + ) + val nowMs = SystemClock.elapsedRealtime() + if (nowMs >= nextDiagnosticSampleAtMs) { + nextDiagnosticSampleAtMs = nowMs + DIAGNOSTIC_SAMPLE_INTERVAL_MS + Log.i( + DIAGNOSTIC_TAG, + "sample request=$requestSequence elapsedMs=${elapsedSince(startedAtMs)} " + + "generation=$generation stream=${diagnosticId(stream.id)} " + + "http=${aggregate.activeHttpRequests} pendingReads=${aggregate.pendingPieceReads} " + + "peers=${aggregate.connectedPeers} seeds=${aggregate.connectedSeeds} " + + "known=${aggregate.knownPeers} candidates=${aggregate.connectCandidates} " + + "interested=${aggregate.interestedPeers} unchoked=${aggregate.unchokedPeers} " + + "downloading=${aggregate.downloadingPeers} snubbed=${aggregate.snubbedPeers} " + + "connecting=${aggregate.connectingPeers} handshaking=${aggregate.handshakingPeers} " + + "targetPeers=${aggregate.targetPiecePeers} " + + "targetUnchoked=${aggregate.targetPieceUnchokedPeers} " + + "targetDownloading=${aggregate.targetPieceDownloadingPeers} " + + "offTargetDownloading=${aggregate.offTargetDownloadingPeers} " + + "pendingBlocks=${aggregate.pendingBlockRequests} " + + "targetBlocks=${aggregate.targetBlockRequests} " + + "timedOutBlocks=${aggregate.timedOutBlockRequests} " + + "downBps=${aggregate.downloadRateBytesPerSecond} upBps=${aggregate.uploadRateBytesPerSecond} " + + "payloadDown=${aggregate.totalPayloadDownloadBytes} " + + "trackerReplies=${aggregate.trackerReplyEvents} " + + "trackerErrors=${aggregate.trackerErrorEvents} " + + "trackerPeers=${aggregate.trackerPeersReturned} " + + "dhtReplies=${aggregate.dhtReplyEvents} dhtPeers=${aggregate.dhtPeersReturned} " + + "peerConnects=${aggregate.peerConnectEvents} " + + "peerDisconnects=${aggregate.peerDisconnectEvents} " + + "disconnectTimeouts=${aggregate.peerDisconnectTimeouts} " + + "disconnectConnectFailures=${aggregate.peerDisconnectConnectFailures} " + + "disconnectRedundant=${aggregate.peerDisconnectRedundant} " + + "disconnectTurnover=${aggregate.peerDisconnectTurnover} " + + "disconnectOther=${aggregate.peerDisconnectOther} " + + "finishedTransitions=${aggregate.torrentFinishedEvents} " + + "contiguous=${route?.contiguousReadyBytes ?: -1L} " + + "verified=${route?.verifiedFileBytes ?: -1L} delivered=${route?.deliveredBytes ?: -1L} " + + "demands=${route?.activeDemands ?: -1} scheduledPieces=${route?.scheduledPieces ?: -1} " + + "blockingPieces=${route?.blockingPieces ?: -1} " + + "blockingPrimary=${route?.primaryBlockingPiece ?: -1} " + + "blockingSecondary=${route?.secondaryBlockingPiece ?: -1} " + + "demandPrimary=${route?.primaryDemandStart ?: -1L}-${route?.primaryDemandEnd ?: -1L} " + + "demandSecondary=${route?.secondaryDemandStart ?: -1L}-${route?.secondaryDemandEnd ?: -1L} " + + "lastReadyPiece=${route?.lastReadyPiece ?: -1} " + + "scheduleRevision=${route?.scheduleRevision ?: -1L} " + + "fileBytes=${route?.fileSize ?: stream.fileSize} activeTorrents=${aggregate.activeTorrents} " + + "activeStreams=${aggregate.activeStreams} warm=${aggregate.warmTorrents} " + + "quiesced=${aggregate.quiescedTorrents} memUsed=${aggregate.memoryCacheUsedBytes} " + + "memHits=${aggregate.memoryCacheHits} memMisses=${aggregate.memoryCacheMisses} " + + "diskUsed=${aggregate.diskCacheUsedBytes} diskProtected=${aggregate.diskCacheProtectedBytes} " + + "diskOverBudget=${aggregate.diskCacheOverBudget}", + ) + } + updateStreamingIfCurrent(generation) { latestState -> + latestState.copy( + downloadSpeed = aggregate.downloadRateBytesPerSecond, + uploadSpeed = aggregate.uploadRateBytesPerSecond, + peers = aggregate.connectedPeers, + seeds = aggregate.connectedSeeds, + bufferProgress = route?.bufferProgress ?: latestState.bufferProgress, + totalProgress = route?.fileProgress ?: latestState.totalProgress, + downloadedBytes = (aggregate.totalPayloadDownloadBytes - + payloadDownloadBaseline).coerceAtLeast(0L), + verifiedBytes = route?.verifiedFileBytes ?: latestState.verifiedBytes, + deliveredBytes = route?.deliveredBytes ?: latestState.deliveredBytes, + ) + } + } + delay(250L) + } } + } - private fun attachTorrentIfCurrent(generation: Long, hash: String): Boolean = - synchronized(lifecycleLock) { - if (streamGeneration != generation) return@synchronized false - currentHash = hash - true + private fun updateCacheState(usedBytes: Long, protectedBytes: Long) { + _cacheState.value = _cacheState.value.copy( + usedBytes = usedBytes, + protectedBytes = protectedBytes, + hasMeasurement = true, + ) + } + + private fun startStartupStatsPolling( + activeEngine: NuvioEngine, + generation: Long, + phase: AtomicReference, + requestSequence: Long, + startedAtMs: Long, + ): Job = scope.launch { + while (isActive) { + val aggregate = activeEngine.stats.value + updateConnectingIfCurrent( + generation = generation, + phase = phase.get(), + downloadSpeed = aggregate.downloadRateBytesPerSecond, + uploadSpeed = aggregate.uploadRateBytesPerSecond, + peers = aggregate.connectedPeers, + seeds = aggregate.connectedSeeds, + ) + Log.i( + DIAGNOSTIC_TAG, + "startupSample request=$requestSequence phase=${phase.get()} elapsedMs=${elapsedSince(startedAtMs)} " + + "http=${aggregate.activeHttpRequests} pendingReads=${aggregate.pendingPieceReads} " + + "peers=${aggregate.connectedPeers} seeds=${aggregate.connectedSeeds} " + + "known=${aggregate.knownPeers} candidates=${aggregate.connectCandidates} " + + "interested=${aggregate.interestedPeers} unchoked=${aggregate.unchokedPeers} " + + "downloading=${aggregate.downloadingPeers} snubbed=${aggregate.snubbedPeers} " + + "connecting=${aggregate.connectingPeers} handshaking=${aggregate.handshakingPeers} " + + "pendingBlocks=${aggregate.pendingBlockRequests} " + + "targetBlocks=${aggregate.targetBlockRequests} " + + "timedOutBlocks=${aggregate.timedOutBlockRequests} " + + "downBps=${aggregate.downloadRateBytesPerSecond} upBps=${aggregate.uploadRateBytesPerSecond} " + + "payloadDown=${aggregate.totalPayloadDownloadBytes} activeTorrents=${aggregate.activeTorrents} " + + "trackerReplies=${aggregate.trackerReplyEvents} trackerErrors=${aggregate.trackerErrorEvents} " + + "trackerPeers=${aggregate.trackerPeersReturned} dhtReplies=${aggregate.dhtReplyEvents} " + + "dhtPeers=${aggregate.dhtPeersReturned} peerConnects=${aggregate.peerConnectEvents} " + + "peerDisconnects=${aggregate.peerDisconnectEvents} " + + "activeStreams=${aggregate.activeStreams} warm=${aggregate.warmTorrents} " + + "quiesced=${aggregate.quiescedTorrents}", + ) + delay(DIAGNOSTIC_SAMPLE_INTERVAL_MS) } + } + + private fun beginStreamGeneration(): Long = synchronized(lifecycleLock) { + streamGeneration += 1 + _state.value = P2pStreamingState.Connecting() + streamGeneration + } + + private fun updateConnectingIfCurrent( + generation: Long, + phase: String, + downloadSpeed: Long, + uploadSpeed: Long, + peers: Int, + seeds: Int, + ) = synchronized(lifecycleLock) { + if (streamGeneration != generation || _state.value !is P2pStreamingState.Connecting) { + return@synchronized + } + _state.value = P2pStreamingState.Connecting( + phase = phase, + downloadSpeed = downloadSpeed, + uploadSpeed = uploadSpeed, + peers = peers, + seeds = seeds, + ) + } + + private fun nextDiagnosticRequestSequence(): Long = synchronized(lifecycleLock) { + diagnosticRequestSequence += 1 + diagnosticRequestSequence + } + + private fun attachStreamIfCurrent( + generation: Long, + torrentId: String, + streamId: String, + ): Boolean = synchronized(lifecycleLock) { + if (streamGeneration != generation) return@synchronized false + currentTorrentId = torrentId + currentStreamId = streamId + true + } + + private fun publishStreamingIfCurrent( + generation: Long, + state: P2pStreamingState.Streaming, + ): Boolean = synchronized(lifecycleLock) { + if (streamGeneration != generation || currentStreamId == null) { + return@synchronized false + } + _state.value = state + true + } + + private fun updateStreamingIfCurrent( + generation: Long, + update: (P2pStreamingState.Streaming) -> P2pStreamingState.Streaming, + ) = synchronized(lifecycleLock) { + if (streamGeneration != generation) return@synchronized + val current = _state.value as? P2pStreamingState.Streaming ?: return@synchronized + _state.value = update(current) + } private fun isCurrentGeneration(generation: Long): Boolean = synchronized(lifecycleLock) { streamGeneration == generation } @@ -177,381 +780,50 @@ actual object P2pStreamingEngine { } } - private fun buildMagnetUri(infoHash: String, extraTrackers: List): String { - val trackers = (DEFAULT_TRACKERS + extraTrackers).distinct() - val trackerParams = trackers.joinToString("") { "&tr=$it" } - return "magnet:?xt=urn:btih:$infoHash$trackerParams" - } - - private suspend fun resolveFileIndex(hash: String, requestedIdx: Int?, filename: String?): Int { - val deadline = System.currentTimeMillis() + 15_000L - var files: List = emptyList() - - while (System.currentTimeMillis() < deadline) { - files = api.getTorrentStats(hash)?.files ?: emptyList() - if (files.isNotEmpty()) break - Log.d(TAG, "Waiting for torrent metadata...") - delay(1_000L) - } - - if (files.isEmpty()) { - Log.w(TAG, "No files after metadata timeout, guessing index ${requestedIdx?.plus(1) ?: 1}") - return requestedIdx?.plus(1) ?: 1 - } - - if (!filename.isNullOrBlank()) { - val name = filename.trim() - val exact = files.firstOrNull { file -> - file.path.substringAfterLast('/').equals(name, ignoreCase = true) - } - if (exact != null) { - Log.d(TAG, "File resolved by exact filename match: ${exact.path} -> id=${exact.id}") - return exact.id - } - - val contains = files.firstOrNull { file -> - file.path.contains(name, ignoreCase = true) - } - if (contains != null) { - Log.d(TAG, "File resolved by filename contains match: ${contains.path} -> id=${contains.id}") - return contains.id - } - } - - if (requestedIdx != null) { - val torrServerIndex = requestedIdx + 1 - if (files.any { it.id == torrServerIndex }) { - Log.d(TAG, "File resolved by ID offset: id=$torrServerIndex") - return torrServerIndex - } - } - - if (requestedIdx != null && requestedIdx in files.indices) { - val positionalFile = files[requestedIdx] - Log.d(TAG, "File resolved by positional index: [$requestedIdx] -> ${positionalFile.path} (id=${positionalFile.id})") - return positionalFile.id - } - - val videoFile = files - .filter { file -> - val ext = file.path.substringAfterLast('.', "").lowercase() - ext in VIDEO_EXTENSIONS - } - .maxByOrNull { it.length } - - val result = videoFile?.id ?: files.maxByOrNull { it.length }?.id ?: 1 - Log.d(TAG, "File resolved by largest video fallback: id=$result") - return result - } - - private fun startStatsPolling(hash: String, generation: Long) { - statsJob?.cancel() - statsJob = scope.launch { - while (isActive) { - if (!isCurrentGeneration(generation)) return@launch - try { - val stats = api.getTorrentStats(hash) - val currentState = _state.value - if ( - stats != null && - currentState is P2pStreamingState.Streaming && - isCurrentGeneration(generation) - ) { - _state.value = currentState.copy( - downloadSpeed = stats.downloadSpeed, - uploadSpeed = stats.uploadSpeed, - peers = stats.peers, - seeds = stats.seeds, - preloadedBytes = stats.preloadedBytes, - ) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "Stats polling error", e) - } - delay(1_000L) - } - } - } - private fun requireContext(): Context = appContext ?: throw P2pStreamingException("P2P streaming engine is not initialized") + private fun logPhase(requestSequence: Long, startedAtMs: Long, phase: String) { + Log.i( + DIAGNOSTIC_TAG, + "start request=$requestSequence phase=$phase complete elapsedMs=${elapsedSince(startedAtMs)}", + ) + } + + private fun elapsedSince(startedAtMs: Long): Long = + (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L) + + private fun diagnosticId(value: String?): String = + value?.trim()?.take(12)?.ifBlank { "none" } ?: "none" + + private fun diagnosticLoopbackUrl(value: String): String = runCatching { + val uri = android.net.Uri.parse(value) + "${uri.scheme}://${uri.host}:${uri.port}/…" + }.getOrDefault("unparseable") + + private fun diagnosticMessage(value: String?): String = + value?.replace('\n', ' ')?.replace('\r', ' ')?.take(160) ?: "none" + private val DEFAULT_TRACKERS = listOf( + "udp://zer0day.ch:1337/announce", + "udp://tracker.publictracker.xyz:6969/announce", "udp://tracker.opentrackr.org:1337/announce", + "udp://open.demonii.com:1337/announce", "udp://open.stealth.si:80/announce", - "udp://tracker.openbittorrent.com:6969/announce", - "udp://exodus.desync.com:6969/announce", + "http://tracker.renfei.net:8080/announce", + "udp://udp.tracker.projectk.org:23333/announce", + "udp://tracker.tryhackx.org:6969/announce", "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.startwork.cv:1337/announce", + "udp://tracker.qu.ax:6969/announce", + "udp://tracker.plx.im:6969/announce", + "udp://tracker.nyaa.vc:6969/announce", + "udp://tracker.iperson.xyz:6969/announce", + "udp://tracker.gmi.gd:6969/announce", + "udp://tracker.fnix.net:6969/announce", + "udp://tracker.flatuslifir.is:6969/announce", + "udp://tracker.ducks.party:1984/announce", + "udp://tracker.bluefrog.pw:2710/announce", ) - - private class TorrServerBinary { - private var context: Context? = null - private var process: Process? = null - private val healthClient = OkHttpClient.Builder() - .connectTimeout(2, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - - val baseUrl: String get() = "http://127.0.0.1:$PORT" - - fun initialize(context: Context) { - this.context = context.applicationContext - } - - suspend fun start() = withContext(Dispatchers.IO) { - if (isRunning()) { - Log.d(TAG, "TorrServer already running") - return@withContext - } - - killOrphanedProcess() - - val ctx = requireContext() - val binaryFile = File(ctx.applicationInfo.nativeLibraryDir, "libtorrserver.so") - if (!binaryFile.exists()) { - throw P2pStreamingException("TorrServer binary not found at ${binaryFile.absolutePath}") - } - - if (!binaryFile.canExecute()) { - binaryFile.setExecutable(true) - } - - val configDir = File(ctx.filesDir, "torrserver").also { it.mkdirs() } - val processBuilder = ProcessBuilder( - binaryFile.absolutePath, - "--port", - PORT.toString(), - "--path", - configDir.absolutePath, - ) - processBuilder.directory(configDir) - processBuilder.redirectErrorStream(true) - - Log.d(TAG, "Starting TorrServer on port $PORT from ${binaryFile.absolutePath}") - process = processBuilder.start() - - val proc = process!! - Thread { - try { - proc.inputStream.bufferedReader().forEachLine { line -> - Log.d(TAG, "[server] $line") - } - } catch (_: Exception) { - } - }.apply { - isDaemon = true - start() - } - - val deadline = System.currentTimeMillis() + STARTUP_TIMEOUT_MS - while (System.currentTimeMillis() < deadline) { - if (isRunning()) { - Log.d(TAG, "TorrServer started successfully") - return@withContext - } - if (!isProcessAlive(process)) { - val exitCode = process?.exitValue() ?: -1 - process = null - throw P2pStreamingException("TorrServer process died on startup (exit code $exitCode)") - } - delay(HEALTH_CHECK_INTERVAL_MS) - } - - stop() - throw P2pStreamingException("TorrServer failed to start within ${STARTUP_TIMEOUT_MS / 1000}s") - } - - fun isRunning(): Boolean { - return try { - val request = Request.Builder().url("$baseUrl/echo").build() - healthClient.newCall(request).execute().use { it.isSuccessful } - } catch (e: Exception) { - false - } - } - - fun stop() { - try { - val request = Request.Builder().url("$baseUrl/shutdown").build() - healthClient.newCall(request).execute().close() - } catch (_: Exception) { - } - - process?.let { proc -> - try { - Thread.sleep(3_000L) - if (isProcessAlive(proc)) { - proc.destroyForcibly() - } - } catch (_: Exception) { - proc.destroyForcibly() - } - } - process = null - Log.d(TAG, "TorrServer stopped") - } - - private fun killOrphanedProcess() { - try { - val request = Request.Builder().url("$baseUrl/shutdown").build() - healthClient.newCall(request).execute().close() - Thread.sleep(1_000L) - Log.d(TAG, "Shut down orphaned TorrServer instance") - } catch (_: Exception) { - } - } - - private fun isProcessAlive(proc: Process?): Boolean { - if (proc == null) return false - return try { - proc.exitValue() - false - } catch (_: IllegalThreadStateException) { - true - } catch (_: Exception) { - false - } - } - - private fun requireContext(): Context = - context ?: throw P2pStreamingException("P2P streaming engine is not initialized") - - companion object { - const val PORT = 8091 - private const val STARTUP_TIMEOUT_MS = 15_000L - private const val HEALTH_CHECK_INTERVAL_MS = 200L - } - } - - private data class TorrServerFile( - val id: Int, - val path: String, - val length: Long, - ) - - private data class TorrServerStats( - val downloadSpeed: Long, - val uploadSpeed: Long, - val peers: Int, - val seeds: Int, - val preloadedBytes: Long, - val loadedSize: Long, - val torrentSize: Long, - val files: List, - ) - - private class TorrServerApi( - private val binary: TorrServerBinary, - ) { - private val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .build() - - private val baseUrl: String get() = binary.baseUrl - - suspend fun addTorrent(magnetLink: String, title: String? = null): String? = withContext(Dispatchers.IO) { - val body = JSONObject().apply { - put("action", "add") - put("link", magnetLink) - put("save_to_db", false) - if (title != null) put("title", title) - } - - val request = Request.Builder() - .url("$baseUrl/torrents") - .post(body.toString().toRequestBody(JSON_TYPE)) - .build() - - try { - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - Log.e(TAG, "addTorrent failed: ${response.code}") - return@withContext null - } - val json = JSONObject(response.body?.string() ?: "{}") - val hash = json.optString("hash", "") - Log.d(TAG, "Torrent added: $hash") - hash.ifEmpty { null } - } - } catch (e: Exception) { - Log.e(TAG, "addTorrent error", e) - null - } - } - - suspend fun getTorrentStats(hash: String): TorrServerStats? = withContext(Dispatchers.IO) { - val body = JSONObject().apply { - put("action", "get") - put("hash", hash) - } - - val request = Request.Builder() - .url("$baseUrl/torrents") - .post(body.toString().toRequestBody(JSON_TYPE)) - .build() - - try { - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) return@withContext null - val json = JSONObject(response.body?.string() ?: "{}") - - val files = mutableListOf() - val fileList = json.optJSONArray("file_stats") ?: JSONArray() - for (i in 0 until fileList.length()) { - val file = fileList.getJSONObject(i) - files.add( - TorrServerFile( - id = file.optInt("id", i + 1), - path = file.optString("path", ""), - length = file.optLong("length", 0), - ), - ) - } - - TorrServerStats( - downloadSpeed = json.optLong("download_speed", 0), - uploadSpeed = json.optLong("upload_speed", 0), - peers = json.optInt("active_peers", 0), - seeds = json.optInt("connected_seeders", 0), - preloadedBytes = json.optLong("preloaded_bytes", 0), - loadedSize = json.optLong("loaded_size", 0), - torrentSize = json.optLong("torrent_size", 0), - files = files, - ) - } - } catch (e: Exception) { - Log.w(TAG, "getTorrentStats error", e) - null - } - } - - suspend fun dropTorrent(hash: String) = withContext(Dispatchers.IO) { - val body = JSONObject().apply { - put("action", "drop") - put("hash", hash) - } - - val request = Request.Builder() - .url("$baseUrl/torrents") - .post(body.toString().toRequestBody(JSON_TYPE)) - .build() - - try { - client.newCall(request).execute().close() - Log.d(TAG, "Torrent dropped: $hash") - } catch (e: Exception) { - Log.w(TAG, "dropTorrent error", e) - } - } - - fun getStreamUrl(magnetLink: String, fileIdx: Int): String { - val encodedLink = URLEncoder.encode(magnetLink, "UTF-8") - return "$baseUrl/stream?link=$encodedLink&index=$fileIdx&play" - } - } - - private val JSON_TYPE = "application/json".toMediaType() } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 85fe5fddc..5303fe9e2 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -9,6 +9,7 @@ import android.util.Log import android.util.TypedValue import android.graphics.Typeface import android.os.Build +import android.os.SystemClock import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.util.AttributeSet import androidx.compose.runtime.getValue @@ -79,6 +80,12 @@ import java.net.HttpURLConnection import java.net.URL private const val TAG = "NuvioPlayer" +private const val PLAYER_DIAGNOSTIC_TAG = "NuvioPlayerDiag" + +private class PlaybackDiagnostics { + var prepareStartedAtMs: Long = 0L + var attempt: Int = 0 +} @androidx.annotation.OptIn(UnstableApi::class) @Composable @@ -92,8 +99,11 @@ actual fun PlatformPlayerSurface( useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, + initialPositionMs: Long?, + initialPositionRequestKey: String?, resizeMode: PlayerResizeMode, useNativeController: Boolean, + onInitialPositionHandled: (key: String, handled: Boolean) -> Unit, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, @@ -109,6 +119,7 @@ actual fun PlatformPlayerSurface( sanitizePlaybackResponseHeaders(sourceResponseHeaders), normalizeStreamType(streamType).orEmpty(), useYoutubeChunkedPlayback, + initialPositionRequestKey.orEmpty(), ) var activeEngine by remember(playerSourceKey, playerSettings.androidPlaybackEngine) { mutableStateOf(playerSettings.androidPlaybackEngine.initialAndroidEngine()) @@ -125,13 +136,19 @@ actual fun PlatformPlayerSurface( useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, modifier = modifier, playWhenReady = playWhenReady, + initialPositionMs = initialPositionMs, + initialPositionRequestKey = initialPositionRequestKey, resizeMode = resizeMode, useNativeController = useNativeController, + onInitialPositionHandled = onInitialPositionHandled, onControllerReady = onControllerReady, onSnapshot = onSnapshot, onError = { message -> if (message != null && playerSettings.androidPlaybackEngine == AndroidPlaybackEngine.Auto) { Log.w(TAG, "ExoPlayer failed; falling back to libmpv: $message") + initialPositionRequestKey?.let { key -> + onInitialPositionHandled(key, false) + } activeEngine = ResolvedAndroidPlaybackEngine.Libmpv onError(null) } else { @@ -139,21 +156,28 @@ actual fun PlatformPlayerSurface( } }, ) - ResolvedAndroidPlaybackEngine.Libmpv -> LibmpvPlayerSurface( - sourceUrl = sourceUrl, - sourceAudioUrl = sourceAudioUrl, - sourceHeaders = sourceHeaders, - externalSubtitles = externalSubtitles, - modifier = modifier, - playWhenReady = playWhenReady, - resizeMode = resizeMode, - videoOutput = playerSettings.androidLibmpvVideoOutput, - hardwareDecodingEnabled = playerSettings.androidLibmpvHardwareDecodingEnabled, - yuv420pEnabled = playerSettings.androidLibmpvYuv420pEnabled, - onControllerReady = onControllerReady, - onSnapshot = onSnapshot, - onError = onError, - ) + ResolvedAndroidPlaybackEngine.Libmpv -> { + LaunchedEffect(initialPositionRequestKey) { + initialPositionRequestKey?.let { key -> + onInitialPositionHandled(key, false) + } + } + LibmpvPlayerSurface( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + externalSubtitles = externalSubtitles, + modifier = modifier, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + videoOutput = playerSettings.androidLibmpvVideoOutput, + hardwareDecodingEnabled = playerSettings.androidLibmpvHardwareDecodingEnabled, + yuv420pEnabled = playerSettings.androidLibmpvYuv420pEnabled, + onControllerReady = onControllerReady, + onSnapshot = onSnapshot, + onError = onError, + ) + } } } @@ -181,8 +205,11 @@ private fun ExoPlayerSurface( useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, + initialPositionMs: Long?, + initialPositionRequestKey: String?, resizeMode: PlayerResizeMode, useNativeController: Boolean, + onInitialPositionHandled: (key: String, handled: Boolean) -> Unit, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, @@ -191,6 +218,7 @@ private fun ExoPlayerSurface( val lifecycleOwner = LocalLifecycleOwner.current val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) + val latestOnInitialPositionHandled = rememberUpdatedState(onInitialPositionHandled) val latestPlayWhenReady = rememberUpdatedState(playWhenReady) val coroutineScope = rememberCoroutineScope() @@ -219,7 +247,9 @@ private fun ExoPlayerSurface( sanitizedSourceResponseHeaders, normalizedStreamType.orEmpty(), useYoutubeChunkedPlayback, + initialPositionRequestKey.orEmpty(), ) + val playbackDiagnostics = remember(playerSourceKey) { PlaybackDiagnostics() } var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) } var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf(null) } val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs) @@ -262,6 +292,7 @@ private fun ExoPlayerSurface( } val dataSourceFactory = remember( context, + sourceUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders, useYoutubeChunkedPlayback, @@ -272,6 +303,7 @@ private fun ExoPlayerSurface( defaultRequestHeaders = sanitizedSourceHeaders, defaultResponseHeaders = sanitizedSourceResponseHeaders, useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, + useLongReadTimeout = isLoopbackPlaybackSource(sourceUrl), externalSubtitles = externalSubtitles, ) } @@ -302,6 +334,7 @@ private fun ExoPlayerSurface( normalizedStreamType, useYoutubeChunkedPlayback, effectiveDecoderPriority, + initialPositionRequestKey, ) { val renderersFactory = SubtitleOffsetRenderersFactory( context = context, @@ -389,9 +422,28 @@ private fun ExoPlayerSurface( onDispose { nowPlayingController.release() } } - LaunchedEffect(exoPlayer, resolvedMediaItem) { + LaunchedEffect(exoPlayer, resolvedMediaItem, initialPositionRequestKey) { val mediaItem = resolvedMediaItem ?: return@LaunchedEffect - exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs) + val requestedStartPositionMs = fallbackStartPositionMs + ?: initialPositionMs?.takeIf { it > 0L } + playbackDiagnostics.attempt += 1 + playbackDiagnostics.prepareStartedAtMs = SystemClock.elapsedRealtime() + Log.i( + PLAYER_DIAGNOSTIC_TAG, + "prepare begin attempt=${playbackDiagnostics.attempt} " + + "source=${diagnosticPlaybackSource(sourceUrl)} audioSource=${!sourceAudioUrl.isNullOrBlank()} " + + "mime=${mediaItem.localConfiguration?.mimeType ?: "auto"} " + + "startPositionMs=${requestedStartPositionMs ?: 0L}", + ) + exoPlayer.setPlaybackMediaItem(mediaItem, requestedStartPositionMs) + if (fallbackStartPositionMs == null) { + initialPositionRequestKey?.let { key -> + latestOnInitialPositionHandled.value( + key, + requestedStartPositionMs != null, + ) + } + } exoPlayer.prepare() } @@ -451,6 +503,14 @@ private fun ExoPlayerSurface( val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { syncPlayerViewKeepScreenOn() + Log.e( + PLAYER_DIAGNOSTIC_TAG, + "error attempt=${playbackDiagnostics.attempt} " + + "elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " + + "code=${error.errorCodeName} cause=${error.cause?.javaClass?.simpleName ?: "none"} " + + "message=${diagnosticPlayerMessage(error.message)}", + error, + ) val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW || error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED || @@ -503,6 +563,14 @@ private fun ExoPlayerSurface( else -> "UNKNOWN($playbackState)" } Log.d(TAG, "onPlaybackStateChanged: $stateName") + Log.i( + PLAYER_DIAGNOSTIC_TAG, + "state=$stateName attempt=${playbackDiagnostics.attempt} " + + "elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " + + "positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)} " + + "bufferedMs=${exoPlayer.bufferedPosition.coerceAtLeast(0L)} " + + "bufferedPercent=${exoPlayer.bufferedPercentage} playWhenReady=${exoPlayer.playWhenReady}", + ) if (playbackState == Player.STATE_READY) { fallbackStartPositionMs = null latestOnError.value(null) @@ -517,10 +585,25 @@ private fun ExoPlayerSurface( } override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.i( + PLAYER_DIAGNOSTIC_TAG, + "isPlaying=$isPlaying attempt=${playbackDiagnostics.attempt} " + + "elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " + + "positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)}", + ) syncPlayerViewKeepScreenOn() dispatchExoPlayerSnapshot() } + override fun onRenderedFirstFrame() { + Log.i( + PLAYER_DIAGNOSTIC_TAG, + "firstFrame attempt=${playbackDiagnostics.attempt} " + + "elapsedMs=${diagnosticElapsedSince(playbackDiagnostics.prepareStartedAtMs)} " + + "positionMs=${exoPlayer.currentPosition.coerceAtLeast(0L)}", + ) + } + override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) { dispatchExoPlayerSnapshot() } @@ -1895,6 +1978,26 @@ private fun guessSubtitleMime(url: String): String { } } +private fun diagnosticElapsedSince(startedAtMs: Long): Long = + if (startedAtMs <= 0L) -1L else (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L) + +private fun diagnosticPlaybackSource(value: String): String = runCatching { + val uri = Uri.parse(value) + val host = uri.host.orEmpty() + val isLoopback = host == "127.0.0.1" || host == "localhost" || host == "::1" + "scheme=${uri.scheme ?: "none"},host=${host.ifBlank { "none" }},port=${uri.port},loopback=$isLoopback" +}.getOrDefault("unparseable") + +private fun isLoopbackPlaybackSource(value: String): Boolean = runCatching { + when (Uri.parse(value).host.orEmpty().lowercase()) { + "127.0.0.1", "localhost", "::1" -> true + else -> false + } +}.getOrDefault(false) + +private fun diagnosticPlayerMessage(value: String?): String = + value?.replace('\n', ' ')?.replace('\r', ' ')?.take(160) ?: "none" + internal class SubtitleRequestHeaderDataSourceFactory( private val upstreamFactory: DataSource.Factory, private val externalSubtitles: List, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt index b13954a07..c5fdf7ca3 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -90,7 +90,10 @@ internal class AndroidPlayerNowPlayingController( private var metadata: AndroidNowPlayingMetadata? = null private var snapshot = PlayerPlaybackSnapshot() - private var artworkBitmap: Bitmap? = null + private var artworkArt: Bitmap? = null + private var artworkAlbumArt: Bitmap? = null + private var artworkDisplayIcon: Bitmap? = null + private var artworkNotificationIcon: Bitmap? = null private var released = false private var lastPublishedPositionMs = Long.MIN_VALUE private var lastPublishedDurationMs = Long.MIN_VALUE @@ -126,7 +129,10 @@ internal class AndroidPlayerNowPlayingController( mediaSession.isActive = true if (artworkChanged) { - artworkBitmap = null + artworkArt = null + artworkAlbumArt = null + artworkDisplayIcon = null + artworkNotificationIcon = null loadArtwork(normalized.artworkUrl) } @@ -184,7 +190,10 @@ internal class AndroidPlayerNowPlayingController( artworkGeneration.incrementAndGet() metadata = null snapshot = PlayerPlaybackSnapshot() - artworkBitmap = null + artworkArt = null + artworkAlbumArt = null + artworkDisplayIcon = null + artworkNotificationIcon = null resetPublishedPlaybackState() mediaSession.setMetadata(null) mediaSession.setPlaybackState( @@ -209,13 +218,23 @@ internal class AndroidPlayerNowPlayingController( snapshot.durationMs.takeIf { it > 0L }?.let { durationMs -> builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) } - artworkBitmap?.let { artwork -> - builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork) - builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork) - builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork) + + val base = builder.build() + val withArtwork = artworkArt?.takeIf { !it.isRecycled }?.let { art -> + MediaMetadata.Builder(base) + .putBitmap(MediaMetadata.METADATA_KEY_ART, art) + .apply { + artworkAlbumArt?.takeIf { !it.isRecycled } + ?.let { putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) } + artworkDisplayIcon?.takeIf { !it.isRecycled } + ?.let { putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, it) } + } + .build() } - mediaSession.setMetadata(builder.build()) + if (withArtwork == null || runCatching { mediaSession.setMetadata(withArtwork) }.isFailure) { + runCatching { mediaSession.setMetadata(base) } + } } private fun publishPlaybackState(force: Boolean) { @@ -275,7 +294,7 @@ internal class AndroidPlayerNowPlayingController( sessionToken = mediaSession.sessionToken, metadata = currentMetadata, snapshot = snapshot, - artwork = artworkBitmap, + artwork = artworkNotificationIcon?.takeIf { !it.isRecycled }, ) PlayerNowPlayingService.publish(appContext, notification) } @@ -291,10 +310,12 @@ internal class AndroidPlayerNowPlayingController( mainHandler.post { if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) { - bitmap?.recycle() return@post } - artworkBitmap = bitmap + artworkArt = bitmap + artworkAlbumArt = bitmap?.let(::copyArtwork) + artworkDisplayIcon = bitmap?.let(::copyArtwork) + artworkNotificationIcon = bitmap?.let(::copyArtwork) publishMetadata() publishNotification() } @@ -533,6 +554,9 @@ private fun downloadArtwork(urlString: String): Bitmap? { } } +private fun copyArtwork(bitmap: Bitmap): Bitmap? = + if (bitmap.isRecycled) null else runCatching { bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false) }.getOrNull() + private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt index 075ce0e4b..3d3e2f19e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt @@ -60,10 +60,28 @@ internal object PlayerPlaybackNetworking { .build() } - fun createHttpDataSourceFactory(defaultHeaders: Map = emptyMap()): DataSource.Factory { + private val loopbackPlaybackHttpClient: OkHttpClient by lazy { + playbackHttpClient.newBuilder() + .addInterceptor { chain -> + val request = chain.request() + val requestChain = if (isLoopbackHost(request.url.host)) { + chain.withReadTimeout(65, TimeUnit.SECONDS) + } else { + chain + } + requestChain.proceed(request) + } + .build() + } + + fun createHttpDataSourceFactory( + defaultHeaders: Map = emptyMap(), + useLongReadTimeout: Boolean = false, + ): DataSource.Factory { val requestHeaders = sanitizeHeaders(defaultHeaders) + val baseClient = if (useLongReadTimeout) loopbackPlaybackHttpClient else playbackHttpClient val client = requestHeaders.headerValue("Authorization")?.let { authorization -> - playbackHttpClient.newBuilder() + baseClient.newBuilder() .addNetworkInterceptor { chain -> val request = chain.request() if (request.header("Authorization") == null) { @@ -77,7 +95,7 @@ internal object PlayerPlaybackNetworking { } } .build() - } ?: playbackHttpClient + } ?: baseClient return OkHttpDataSource.Factory(client).apply { setDefaultRequestProperties(requestHeaders) @@ -90,8 +108,12 @@ internal object PlayerPlaybackNetworking { fun createDataSourceFactory( context: Context, defaultHeaders: Map = emptyMap(), + useLongReadTimeout: Boolean = false, ): DataSource.Factory { - return DefaultDataSource.Factory(context, createHttpDataSourceFactory(defaultHeaders)) + return DefaultDataSource.Factory( + context, + createHttpDataSourceFactory(defaultHeaders, useLongReadTimeout), + ) } fun openConnection( @@ -133,6 +155,11 @@ internal object PlayerPlaybackNetworking { } }.toMap() + private fun isLoopbackHost(host: String): Boolean = when (host.lowercase()) { + "127.0.0.1", "localhost", "::1" -> true + else -> false + } + private fun withDefaultUserAgent(headers: Map): Map { val sanitized = sanitizeHeaders(headers) if (sanitized.headerValue("User-Agent") != null) return sanitized diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt index cc8e84365..d694069b1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.android.kt @@ -14,13 +14,13 @@ internal actual object TraktAuthStorage { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) } - actual fun loadPayload(): String? = - preferences?.getString(ProfileScopedKey.of(payloadKey), null) + actual fun loadPayload(profileId: Int): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey, profileId), null) - actual fun savePayload(payload: String) { + actual fun savePayload(profileId: Int, payload: String) { preferences ?.edit() - ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.putString(ProfileScopedKey.of(payloadKey, profileId), payload) ?.apply() } } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt index f2d30fa7e..73f15959d 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/player/PlatformPlaybackDataSourceFactory.android.kt @@ -10,9 +10,13 @@ internal object PlatformPlaybackDataSourceFactory { defaultRequestHeaders: Map, defaultResponseHeaders: Map, useYoutubeChunkedPlayback: Boolean, + useLongReadTimeout: Boolean = false, externalSubtitles: List = emptyList(), ): DataSource.Factory { - val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders) + val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory( + defaultRequestHeaders, + useLongReadTimeout, + ) val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory( upstreamFactory = httpFactory, externalSubtitles = externalSubtitles @@ -27,4 +31,4 @@ internal object PlatformPlaybackDataSourceFactory { ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/composeResources/drawable/rating_mal.png b/composeApp/src/commonMain/composeResources/drawable/rating_mal.png new file mode 100644 index 000000000..1bbf12ce0 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/rating_mal.png differ diff --git a/composeApp/src/commonMain/composeResources/values-bg/strings.xml b/composeApp/src/commonMain/composeResources/values-bg/strings.xml index b14cd5fff..f75074db8 100644 --- a/composeApp/src/commonMain/composeResources/values-bg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bg/strings.xml @@ -415,12 +415,14 @@ Лицензи и авторство MDBList оценки Страница с детайли + СТРАНИЦА С ДЕТАЙЛИ Известия Възпроизвеждане Плъгини Стил на картичката с постер Настройки Потоци + ПОТОЦИ Поддръжници и сътрудници TMDB обогатяване Trakt @@ -561,8 +563,8 @@ КАТАЛОЗИ КАТАЛОЗИ И КОЛЕКЦИИ КОЛЕКЦИИ - Оформление на началото - Основни каталози + ОФОРМЛЕНИЕ НА НАЧАЛОТО + ОСНОВНИ КАТАЛОЗИ %1$d от %2$d избрани Покажи основната секция Покажи въртележка в горната част на началото. @@ -576,7 +578,7 @@ Скрий стойността Плейър, субтитри и автоматично възпроизвеждане Радиус на ъглите - Стил на картичката с постер + СТИЛ НА КАРТИЧКАТА С ПОСТЕР Ширина Персонализиран Настрой ширината на картичката и радиуса на ъглите. @@ -611,7 +613,7 @@ Сортирай всички елементи по актуалност потокинг стил Излезлите елементи първо, предстоящите в края - Стил на картичката с постер + СТИЛ НА КАРТИЧКАТА С ПОСТЕР ПРИ СТАРТИРАНЕ ПОВЕДЕНИЕ НА „СЛЕДВАЩО" ВИДИМОСТ @@ -634,11 +636,11 @@ Настрой оформлението на началото, видимостта на съдържанието и поведението на постерите Настройки за екраните с детайли и епизоди. Създавай персонализирани групи от каталози с папки, показвани на началото. - Интеграции + ИНТЕГРАЦИИ Контроли за обогатяване на метаданни Доставчици на външни оценки Свързвай акаунти за връзки и достъп до библиотека - Свързани услуги + СВЪРЗАНИ УСЛУГИ Тези интеграции са експериментални и могат да бъдат запазени, променени или премахнати по-късно. Облачна библиотека Разглеждай и пускай файлове вече в свързаните акаунти. @@ -647,7 +649,7 @@ Разреши с Избери кой свързан акаунт обработва пускащи се връзки. Първо свържете акаунт. - Акаунти + АКАУНТИ Свържете вашия акаунт в %1$s. Свържете вашия акаунт в %1$s чрез браузъра. %1$s API ключ @@ -667,26 +669,26 @@ Не можа да се стартира влизането. Този метод за влизане не е конфигуриран в тази версия. Кодът изтече. Опитайте отново. - Подготовка на връзки + ПОДГОТОВКА НА ВРЪЗКИ Подготви връзки Разреши пускащи се връзки преди да започне възпроизвеждането. Връзки за подготовка Използвайте по-малък брой, когато е възможно. Свързаните услуги може да ограничат броя на връзките, които могат да бъдат разрешени за период от време. Отварянето на филм или епизод може да се брои за тези ограничения, дори ако не натиснете Гледай, тъй като връзките се подготвят предварително. 1 връзка %1$d връзки - Форматиране + ФОРМАТИРАНЕ Шаблон за ime Контролира как се показват имената на резултатите. Оставете празно, за да използвате оригиналното ime на резултата. Шаблон за описание Контролира метаданните, показани под всеки резултат. Оставете празно, за да използвате оригиналните детайли на резултата. Нулирай форматирането Възстанови форматирането на резултатите по подразбиране. - Fusion стил + FUSION СТИЛ Значки за размер Покажи значки за размер на файла в резултатите от потоци и панелите с източници на плейъра. Лого на добавка Покажи логото и името на добавката до изворите на потоци. - Показване + ПОКАЗВАНЕ Позиция на значката Изберете дали Fusion и значките за размер да се показват над или под картичките с потоци. Позиция на значката @@ -717,9 +719,9 @@ API ключ Активирай MDBList оценки Извличай оценки от външни доставчици в екрана с детайли на метаданните - API ключ - Доставчици на външни оценки - MDBList оценки + API КЛЮЧ + ДОСТАВЧИЦИ НА ВЪНШНИ ОЦЕНКИ + MDBList ОЦЕНКИ Действия Контроли за пускане и запазване. Актьорски състав @@ -987,7 +989,7 @@ ИДЕНТИФИКАЦИОННИ ДАННИ ЛОКАЛИЗАЦИЯ МОДУЛИ - TMDB обогатяване + TMDB ОБОГАТЯВАНЕ След одобрение, ще бъдете пренасочени автоматично. УДОСТОВЕРЯВАНЕ Коментари @@ -1577,7 +1579,7 @@ Персонализиран Използвай разширените стойности по-долу. Изкл. - iOS видео изход + iOS ВИДЕО ИЗХОД Хардуерен декодер Разширен динамичен обхват Режим на Metal изход по подразбиране за нови сесии на възпроизвеждане. @@ -1585,12 +1587,12 @@ Нека mpv насочва цветовото пространство на активния дисплей по подразбиране. Целеви примари Целево пренасяне - iOS аудио изход + iOS АУДИО ИЗХОД Аудио изход Опитай AVFoundation първо, след това се върни към AudioUnit. Експериментална поддръжка за Spatial Audio и многоканален изход. Използвай стария AudioUnit изход. - Управление на резултатите + УПРАВЛЕНИЕ НА РЕЗУЛТАТИТЕ Макс. резултати Ограничи колко резултата се показват. Сортирай резултатите @@ -1834,6 +1836,7 @@ Активирай P2P Отказ P2P потокинг + P2P ПОТОКИНГ Разрешете peer-to-peer (торент) потоци Скрий статистиките на торента Скрий буфера, сийдовете, пиъровете и скоростта на изтегляне по време на зареждане и възпроизвеждане diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index 039e9d5d8..5a460acd4 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -426,12 +426,14 @@ Licence & autorství Hodnocení MDBList Stránka podrobností + STRÁNKA PODROBNOSTÍ Oznámení Přehrávání Pluginy Styl karty plakátu Nastavení Streamy + STREAMY Podporovatelé a přispěvatelé Obohacení TMDB Trakt @@ -573,8 +575,8 @@ KATALOGY KATALOGY A SBÍRKY SBÍRKY - Rozvržení domů - Katalogy pro Hero + ROZVRŽENÍ DOMŮ + KATALOGY PRO HERO %1$d z %2$d vybráno Zobrazit Hero sekci Zobrazit hlavní karusel v horní části domovské stránky. @@ -588,7 +590,7 @@ Skrýt hodnotu Přehrávač, titulky a automatické přehrávání Poloměr rohů - Styl karty plakátu + STYL KARTY PLAKÁTU Šířka Vlastní Vyladit šířku karty a poloměr rohů. @@ -623,7 +625,7 @@ Seřadit všechny položky podle nejnovějších Styl streamování Nejprve vydané položky, nadcházející na konci - Styl karty plakátu + STYL KARTY PLAKÁTU PŘI SPUŠTĚNÍ CHOVÁNÍ "DALŠÍ NA ŘADĚ" VIDITELNOST @@ -647,11 +649,11 @@ Upravit rozvržení domovské obrazovky, viditelnost obsahu a chování plakátů. Nastavení pro obrazovku detailu a epizody. Vytvářet vlastní seskupení katalogů se složkami na domovské obrazovce. - Integrace + INTEGRACE Ovládání pro obohacování metadat Externí poskytovatelé hodnocení Připojit účty pro odkazy a přístup ke knihovně - Připojené služby + PŘIPOJENÉ SLUŽBY Tyto integrace jsou experimentální a mohou být zachovány, změněny nebo později odstraněny. Cloudová knihovna Procházet a přehrávat soubory, které už jsou ve vašich připojených účtech. @@ -660,7 +662,7 @@ Zpracovávat pomocí Vyberte, který připojený účet bude zpracovávat přehrávatelné odkazy. Nejprve připojte účet. - Účty + ÚČTY Připojte svůj účet na %1$s. Propojte svůj %1$s účet v prohlížeči. API klíč k %1$s @@ -680,26 +682,26 @@ Nepodařilo se zahájit přihlášení. Tato metoda přihlášení není v tomto buildu nakonfigurována. Tento kód vypršel. Zkuste to znovu. - Příprava odkazů + PŘÍPRAVA ODKAZŮ Připravit odkazy Zpracovat přehrávatelné odkazy dříve, než začne přehrávání. Odkazy k přípravě Kdykoli je to možné, použijte nižší počet. Připojené služby mohou omezovat, kolik odkazů lze vyhodnotit za určité časové období. Otevření filmu nebo epizody se může započítat do těchto limitů, i když nestisknete Přehrát, protože odkazy se připravují předem. 1 odkaz %1$d odkazů - Formátování + FORMÁTOVÁNÍ Šablona názvu Ovládá, jak se budou názvy výsledků zobrazovat. Ponechte prázdné pro použití původního názvu výsledku. Šablona popisu Ovládá metadata zobrazená pod každým výsledkem. Ponechte prázdné pro použití původních detailů výsledku. Obnovit formátování Obnovit výchozí formátování výsledků. - Styl Fusion + STYL FUSION Odznaky velikosti Zobrazovat odznaky velikosti souboru ve výsledcích streamu a na panelech zdroje v přehrávači. Logo doplňku Zobrazovat logo a název doplňku vedle zdrojů streamů. - Zobrazení + ZOBRAZENÍ Pozice odznaku Vyberte si, zda se budou odznaky Fusion a velikosti zobrazovat nad nebo pod kartami streamu. Pozice odznaku @@ -734,9 +736,9 @@ API klíč Povolit hodnocení MDBList Načítat hodnocení od externích poskytovatelů na obrazovce podrobností metadat - API klíč - Externí poskytovatelé hodnocení - Hodnocení MDBList + API KLÍČ + EXTERNÍ POSKYTOVATELÉ HODNOCENÍ + HODNOCENÍ MDBList Akce Ovládání přehrávání a ukládání. Obsazení @@ -1024,7 +1026,7 @@ POVĚŘENÍ LOKALIZACE MODULY - Obohacení TMDB + OBOHACENÍ TMDB Po schválení budete automaticky přesměrováni zpět. OVĚŘENÍ (AUTHENTICATION) Komentáře @@ -1633,7 +1635,7 @@ Použije vaše pokročilé hodnoty níže. Vyp - Výstup videa pro iOS + VÝSTUP VIDEA PRO iOS Hardwarový dekodér Rozšířený dynamický rozsah Výchozí režim výstupu Metal pro nové relace přehrávání. @@ -1642,13 +1644,13 @@ Cílové základní barvy Cílový přenos - Výstup zvuku pro iOS + VÝSTUP ZVUKU PRO iOS Výstup zvuku Použít AudioUnit, dokud je výstup AVFoundation dočasně zakázán. Experimentální podpora pro prostorový zvuk (Spatial Audio) a vícekanálový výstup. Použít starší výstup AudioUnit. - Správa výsledků + SPRÁVA VÝSLEDKŮ Max výsledků Omezit, kolik výsledků se zobrazí. Seřadit výsledky @@ -1916,6 +1918,7 @@ Zrušit Neznámá chyba torrentu P2P Streamování + P2P STREAMOVÁNÍ Povolit peer-to-peer (torrent) streamy Skrýt statistiky torrentu Skrýt načítání (buffer), seedery, peery a rychlost stahování během načítání a přehrávání diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 09a975020..cfc55c3bd 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -375,6 +375,7 @@ Integrationen MDBList-Bewertungen Meta-Bildschirm + META-BILDSCHIRM Benachrichtigungen Wiedergabe Plugins diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index a89884225..61e4d9d70 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -218,6 +218,7 @@ Ενσωματώσεις Αξιολογήσεις MDBList Οθόνη μεταδεδομένων + ΟΘΌΝΗ ΜΕΤΑΔΕΔΟΜΈΝΩΝ Ειδοποιήσεις Αναπαραγωγή Πρόσθετα εφαρμογών @@ -355,7 +356,7 @@ Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε. Ορατό Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση - Εφέ βάθους κάρτας + ΕΦΈ ΒΆΘΟΥΣ ΚΆΡΤΑΣ Προσθέτει φωτεινό επάνω άκρο και απαλή λάμψη στις κάρτες εικόνας για αίσθηση βάθους. Ενεργοποίηση εφέ βάθους Λάμψη άκρου @@ -693,6 +694,7 @@ Rotten Tomatoes TMDB Trakt + MyAnimeList Άγνωστο Κεχριμπάρι Κόκκινο @@ -1078,8 +1080,19 @@ %1$s - %2$s Οι αποθηκευμένοι τίτλοι θα εμφανιστούν εδώ αφού πατήσετε Αποθήκευση σε μια οθόνη λεπτομερειών. Η βιβλιοθήκη σας είναι κενή + Όλοι οι τύποι + Επιλογή λίστας + Ταξινόμηση βιβλιοθήκης + Επιλογή τύπου + Εμφάνιση οριζόντιων ραφιών + Εμφάνιση κατακόρυφου πλέγματος Αδυναμία φόρτωσης βιβλιοθήκης Άλλο + Παλαιότερη προσθήκη + Πρόσφατη προσθήκη + Τίτλος Α–Ω + Τίτλος Ω–Α + Σειρά Trakt Βιβλιοθήκη Συνδέστε το Trakt και αποθηκεύστε τίτλους στη λίστα παρακολούθησης ή σε προσωπικές λίστες. Η βιβλιοθήκη Trakt σας είναι κενή @@ -1144,7 +1157,7 @@ Τίποτα εδώ ακόμα Επιλέξτε ένα αρχείο για αναπαραγωγή Δεν ήταν δυνατή η φόρτωση της βιβλιοθήκης Cloud %1$s - Αυτό το στοιχείο δεν εκθέτει ένα αναπαιγαίμο αρχείο βίντεο. + Αυτό το στοιχείο δεν εκθέτει ένα αναπαίξιμο αρχείο βίντεο. Δεν υπάρχουν αναπαίξιμα αρχεία Δεν υπάρχουν αναπαίξιμα αρχεία Η βιβλιοθήκη Cloud είναι απενεργοποιημένη. @@ -1157,6 +1170,7 @@ Όλα Η βιβλιοθήκη Cloud δεν είναι διαθέσιμη για %1$s. Ανανέωση βιβλιοθήκης Cloud + Αναζήτηση βιβλιοθήκης Cloud Επιλογή παρόχου Επιλογή τύπου Έτοιμο για αναπαραγωγή @@ -1419,6 +1433,7 @@ Συνδεδεμένος Services Άδειες και αποδόσεις Ροές + ΡΟΈΣ Συμπεριφορά εκκίνησης και προφίλ. ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ Εμφάνιση αποτελεσμάτων ροής και κανόνες URL badge. @@ -1452,7 +1467,7 @@ Δεν υπάρχει διαθέσιμος εξωτερικός player απομένουν %1$dω %2$dλ απομένουν %1$dλ - Απόκρυψη Unreleased Content + Απόκρυψη μη κυκλοφορημένου περιεχομένου Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα. Εμφάνιση τύπου καταλόγου Εμφάνιση επιθήματος τύπου δίπλα στο όνομα καταλόγου (Ταινία/Σειρές). @@ -1489,7 +1504,7 @@ Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. Αποτυχία εκκίνησης torrent: %1$s Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. - Torrent Σφάλμα: %1$s + Σφάλμα torrent: %1$s Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής. Λήψη υποτίτλων... Φόρτωση υποτίτλων από πρόσθετα... @@ -1502,10 +1517,14 @@ Εγγενές EDR Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR. Χαρτογράφηση τόνου SDR - %1$s buffered · %2$s · %3$s + %1$s στην προσωρινή μνήμη · %2$s · %3$s Σύνδεση με peers… %1$d seeds · %2$d peers Εκκίνηση μηχανισμού P2P… + Λήψη μεταδεδομένων torrent… + Προετοιμασία ροής torrent… + %1$s · %2$s · %3$s + %1$s ληφθέντα · %2$s · %3$s %1$d peers · %2$d seeds · %3$d%% %1$s · %2$s Φωτεινότητα @@ -1584,7 +1603,7 @@ Κατάργηση αποθηκευμένων δεδομένων Συνέχειας παρακολούθησης και ανανέωση προόδου παρακολούθησης Απομνημόνευση τελευταίου προφίλ Απομνημόνευση του τελευταίου επιλεγμένου προφίλ κατά την εκκίνηση - Προσωρινή μνήμη + ΠΡΟΣΩΡΙΝΉ ΜΝΉΜΗ ΕΚΚΙΝΗΣΗ Γλώσσα συσκευής Υγρό γυαλί @@ -1599,6 +1618,9 @@ Ταξινόμηση όλων των στοιχείων κατά πρόσφατη δραστηριότητα Στυλ streaming Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος + Ξεχωριστή σειρά επερχόμενων + Μετακίνηση επερχόμενων επεισοδίων σε ξεχωριστή σειρά Επερχόμενων + Επερχόμενα Σειρά ταξινόμησης Κάρτα Οριζόντια κάρτα σε στυλ τηλεόρασης @@ -1701,11 +1723,11 @@ Εμφάνιση μόνο των επιλεγμένων αναλύσεων. Απαιτούμενες οπτικές ετικέτες Απαιτεί ετικέτες DV, HDR, 10bit, IMAX, SDR και παρόμοιες. - Μορφοποίηση - Προετοιμασία συνδέσμων - Λογαριασμοί - Διαχείριση αποτελεσμάτων - Συνδεδεμένος Services + ΜΟΡΦΟΠΟΊΗΣΗ + ΠΡΟΕΤΟΙΜΑΣΊΑ ΣΥΝΔΈΣΜΩΝ + ΛΟΓΑΡΙΑΣΜΟΊ + ΔΙΑΧΕΊΡΙΣΗ ΑΠΟΤΕΛΕΣΜΆΤΩΝ + ΣΥΝΔΕΔΕΜΈΝΟΣ SERVICES Οποιοδήποτε %1$d επιλεγμένα %1$dGB+ @@ -1759,7 +1781,7 @@ Premiumize ΑΔΕΙΑ ΕΦΑΡΜΟΓΗΣ ΔΕΔΟΜΕΝΑ & ΥΠΗΡΕΣΙΕΣ - Αναπαραγωγή LICENSE + ΑΝΑΠΑΡΑΓΩΓΉ LICENSE Το Nuvio χρησιμοποιεί το API του TMDB για μεταδεδομένα ταινιών και σειρών, εικαστικό υλικό, trailer, συμμετέχοντες, λεπτομέρειες παραγωγής, συλλογές και προτάσεις. Αυτό το προϊόν χρησιμοποιεί το API του TMDB αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB. The Ταινία Database (TMDB) Το Nuvio συνδέεται με το TorBox για ταυτοποίηση λογαριασμού, πρόσβαση σε βιβλιοθήκη Cloud, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το Cloud. Το Nuvio δεν σχετίζεται με το TorBox ούτε έχει την υποστήριξή του. @@ -1779,9 +1801,29 @@ Hero Trailer Αναπαραγωγή Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer. Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή - Απόκρυψη torrent stats + Προφίλ torrent + Ήπιο + Χρησιμοποιεί λιγότερες συνδέσεις peers για μείωση φόρτου μπαταρίας και δικτύου + Ισορροπημένο + Προτεινόμενη ισορροπία μεταξύ ταχύτητας εκκίνησης και σταθερότητας σύνδεσης + Γρήγορο + Χρησιμοποιεί περισσότερες συνδέσεις peers για δύσκολα ή υψηλού bitrate torrents + Μέγεθος προσωρινής μνήμης torrent + Χωρίς μόνιμη προσωρινή μνήμη + 2 GB + 5 GB + 10 GB + Εκκαθάριση προσωρινής μνήμης torrent + Χρησιμοποιούνται %1$s αυτή τη στιγμή + Η χρήση μετράται μετά την εκκίνηση του P2P· πατήστε για εκκαθάριση αποθηκευμένων δεδομένων torrent + Εκκαθάριση ανενεργών δεδομένων torrent… + Διακόψτε την αναπαραγωγή P2P πριν εκκαθαρίσετε την προσωρινή μνήμη + Εκκαθαρίστηκαν %1$s ανενεργών δεδομένων torrent + Δεν ήταν δυνατή η εκκαθάριση της προσωρινής μνήμης torrent + Απόκρυψη στατιστικών torrent Επιτρέψτε ροές peer-to-peer (torrent) Ροές P2P + ΡΟΈΣ P2P Όλοι οι υπότιτλοι Λήψη και εμφάνιση κάθε υπότιτλου πρόσθετου για το βίντεο. Γρήγορη εκκίνηση @@ -1813,7 +1855,7 @@ Χρήση AudioUnit ενώ η έξοδος AVFoundation είναι προσωρινά απενεργοποιημένη. Πειραματική υποστήριξη για Spatial Audio και πολυκάναλη έξοδο. Ήχος output - iOS Ήχος output + iOS ΉΧΟΣ OUTPUT Οθόνη color hint Επιτρέψτε στο mpv να στοχεύει τον ενεργό χρωματικό χώρο της οθόνης από προεπιλογή. Εκτεταμένο δυναμικό εύρος @@ -1824,7 +1866,7 @@ Στόχοι πρωτογενών χρωμάτων Στόχος μεταφοράς Στόχος μεταφοράς - iOS Βίντεο output + Έξοδος βίντεο iOS Αποκωδικοποίηση υλικού libmpv Χρήση αποκωδικοποίησης υλικού mpv όταν είναι διαθέσιμη. Renderer libmpv @@ -1876,8 +1918,8 @@ Εισαγωγή έως %1$d URL badge ροής τύπου Fusion σε μορφή JSON. Κάθε URL μπορεί να ενημερωθεί ή να διαγραφεί ξεχωριστά. Διαχείριση εισηγμένων URL badge ροής τύπου Fusion (JSON). URL badge Fusion - Στυλ Fusion - Οθόνη + ΣΤΥΛ FUSION + ΟΘΌΝΗ Εμφάνιση badge μεγέθους αρχείου στα αποτελέσματα ροής και στα πλαίσια πηγών του player. Badge μεγέθους Φόρτωση τμημάτων παράλειψης… diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 02427ae4a..549542983 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -426,12 +426,14 @@ Licencias y atribución Calificaciones de MDBList Pantalla meta + PANTALLA META Notificaciones Reproducción Plugins Personalización de póster Configuración Corrientes + CORRIENTES Patrocinadores y colaboradores Enriquecimiento TMDB Trakt @@ -651,7 +653,7 @@ Mejora las páginas de detalles con arte, créditos, metadatos de episodios y más desde TMDB. Añade calificaciones externas de IMDb, Rotten Tomatoes, Metacritic y otras a las páginas de detalles. Conecte cuentas para enlaces y acceso a la biblioteca - Servicios conectados + SERVICIOS CONECTADOS Estas integraciones son experimentales y pueden conservarse, modificarse o eliminarse más adelante. biblioteca en la nube Explore y reproduzca archivos que ya estén en sus cuentas conectadas. @@ -660,7 +662,7 @@ Resolver con Elija qué cuenta conectada maneja los enlaces reproducibles. Primero conecte una cuenta. - Cuentas + CUENTAS Conecta tu cuenta de %1$s. Vincula tu cuenta de %1$s en el navegador. %1$s clave API @@ -680,26 +682,26 @@ No se pudo iniciar el inicio de sesión. Este método de inicio de sesión no está configurado en esta compilación. Este código expiró. Intentar otra vez. - Preparación de enlaces + PREPARACIÓN DE ENLACES Preparar enlaces Resuelva los enlaces reproducibles antes de que comience la reproducción. Enlaces para preparar Utilice un recuento más bajo cuando sea posible. Los servicios conectados pueden limitar la cantidad de enlaces que se pueden resolver en un período de tiempo. Abrir una película o un episodio puede contar para esos límites incluso si no presiona Ver, porque los enlaces están preparados con anticipación. 1 enlace %1$d enlaces - Formato + FORMATO Plantilla de nombre Controla cómo aparecen los nombres de los resultados. Déjelo en blanco para utilizar el nombre del resultado original. Plantilla de descripción Controla los metadatos que se muestran debajo de cada resultado. Déjelo en blanco para utilizar los detalles del resultado original. Restablecer formato Restaurar el formato de resultados predeterminado. - Estilo de fusión + ESTILO DE FUSIÓN Insignias de tamaño Muestre insignias de tamaño de archivo en los resultados de la transmisión y en los paneles de fuentes del reproductor. Logotipo del complemento Muestra el logotipo y el nombre del complemento junto a las fuentes de transmisión. - Mostrar + MOSTRAR Posición de la insignia Elija si las insignias de tamaño y Fusion aparecen encima o debajo de las tarjetas de transmisión. Posición de la insignia @@ -1633,7 +1635,7 @@ Utilice sus valores avanzados a continuación. Apagado - salida de vídeo iOS + SALIDA DE VÍDEO iOS Decodificador de hardware Rango dinámico extendido Modo de salida Metal predeterminado para nuevas sesiones de reproducción. @@ -1642,13 +1644,13 @@ Primarias objetivo Transferencia de destino - salida de audio de iOS + SALIDA DE AUDIO DE iOS Salida de audio Utilice AudioUnit mientras la salida de AVFoundation esté temporalmente desactivada. Soporte experimental para audio espacial y salida multicanal. Utilice la salida AudioUnit heredada. - Gestión de resultados + GESTIÓN DE RESULTADOS Resultados máximos Limite la cantidad de resultados que aparecen. Ordenar resultados @@ -1916,6 +1918,7 @@ Cancelar Error de torrent desconocido Transmisión P2P + TRANSMISIÓN P2P Permitir transmisiones de igual a igual (torrent) Ocultar estadísticas de torrents Ocultar buffer, semillas, pares y velocidad de descarga durante la carga y reproducción diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index a6a536650..0638c38b7 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -418,12 +418,14 @@ Licences et attributions Notes MDBList Écran méta + ÉCRAN MÉTA Notifications Lecture Plugins Personnalisation des affiches Paramètres Streams + STREAMS Supporters et contributeurs Enrichissement TMDB Trakt @@ -661,7 +663,7 @@ Enrichissez les pages de détails avec de l’art, des crédits, des métadonnées d’épisodes et plus depuis TMDB. Ajoutez des notes externes d’IMDb, Rotten Tomatoes, Metacritic et d’autres aux pages de détails. Connectez des comptes pour les liens et l’accès à la bibliothèque - Services connectés + SERVICES CONNECTÉS Ces intégrations sont expérimentales et peuvent être conservées, modifiées ou supprimées plus tard. Cloud library Parcourez et lisez les fichiers déjà présents dans vos comptes connectés. @@ -670,7 +672,7 @@ Résoudre avec Choisissez quel compte connecté gère les liens lisibles. Connectez d’abord un compte. - Comptes + COMPTES Connectez votre compte %1$s. Liez votre compte %1$s dans le navigateur. Clé API %1$s @@ -690,26 +692,26 @@ Impossible de démarrer la connexion. Cette méthode de connexion n’est pas configurée dans ce build. Ce code a expiré. Réessayez. - Préparation des liens + PRÉPARATION DES LIENS Préparer les liens Résoudre les liens lisibles avant le démarrage de la lecture. Liens à préparer Utilisez un nombre plus faible si possible. Les services connectés peuvent limiter le nombre de liens résolus sur une période donnée. Ouvrir un film ou un épisode peut compter dans ces limites même si vous n’appuyez pas sur Regarder, car les liens sont préparés à l’avance. 1 lien %1$d liens - Mise en forme + MISE EN FORME Modèle de nom Contrôle la manière dont les noms de résultats apparaissent. Modèle de description Contrôle les métadonnées affichées sous chaque résultat. Réinitialiser la mise en forme Restaurer la mise en forme par défaut des résultats. - Style Fusion + STYLE FUSION Badges de taille Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur. Logo de l’addon Affiche le logo et le nom de l’addon à côté des sources de streams. - Affichage + AFFICHAGE Position des badges Choisissez si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux. Position des badges @@ -1647,7 +1649,7 @@ Utilisez vos valeurs avancées ci-dessous. Désactivé - Sortie vidéo iOS + SORTIE VIDÉO iOS Décodeur matériel Plage dynamique étendue Mode de sortie Metal par défaut pour les nouvelles sessions de lecture. @@ -1655,13 +1657,13 @@ Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif. Primaires cibles Transfert cible - Sortie audio iOS + SORTIE AUDIO iOS Sortie audio Utilise AudioUnit tant que la sortie AVFoundation est temporairement désactivée. Prise en charge expérimentale de l’audio spatial et de la sortie multicanal. Utilise l’ancienne sortie AudioUnit. - Gestion des résultats + GESTION DES RÉSULTATS Nombre max de résultats Limite le nombre de résultats affichés. Tri des résultats @@ -1945,6 +1947,7 @@ Annuler Erreur torrent inconnue Streaming P2P + STREAMING P2P Autoriser les flux pair-à-pair (torrent) Masquer les statistiques torrent Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml index 14177d309..f17027ad9 100644 --- a/composeApp/src/commonMain/composeResources/values-he/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml @@ -251,6 +251,7 @@ אינטגרציות רישיונות וקרדיטים עמוד פרטים + עמוד פרטים התראות הפעלה פלאגינים @@ -350,7 +351,7 @@ גדול רגיל הצג ערך - סגנון Fusion + סגנון FUSION תגי גודל הצג תגי גודל קובץ בתוצאות Stream ובפאנלי מקור בנגן. הצגת לוגו התוסף @@ -373,8 +374,8 @@ הפרק הבא נגן דילוג על קטעים - הפעלה אוטומטית של Stream - בחירת Stream + הפעלה אוטומטית של STREAM + בחירת STREAM כתוביות ואודיו עיבוד כתוביות שכבת טעינה @@ -708,6 +709,7 @@ P2P הפעל ביטול סטרימינג P2P + סטרימינג P2P אפשר מקורות Stream עמית-לעמית (חינמי אך טעינה איטית). %1$d מקורות · %2$d עמיתים %1$d עמיתים · %2$d מקורות · %3$d%% diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml index a66a1cfd2..70272fc88 100644 --- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -386,6 +386,7 @@ Licencek és köszönetnyilvánítások MDBList értékelések Részletek oldal + RÉSZLETEK OLDAL Értesítések Lejátszás Beépülő modulok @@ -517,8 +518,8 @@ KATALÓGUSOK KATALÓGUSOK & GYŰJTEMÉNYEK GYŰJTEMÉNYEK - Kezdőlap elrendezése - Kiemelt katalógusok + KEZDŐLAP ELRENDEZÉSE + KIEMELT KATALÓGUSOK %1$d / %2$d kiválasztva Kiemelt rész megjelenítése Kiemelt galéria (carousel) megjelenítése a kezdőlap tetején. @@ -532,7 +533,7 @@ Érték elrejtése Lejátszó, feliratok és automatikus lejátszás Sarokkerekítés - Poszterkártya stílusa + POSZTERKÁRTYA STÍLUSA Szélesség Egyéni Kártyaszélesség és sarokkerekítés finomhangolása. @@ -566,7 +567,7 @@ Minden elem rendezése a legutóbbi megtekintés alapján Streaming stílus A már megjelent elemek előre, a közelgők a sor végére kerülnek - Poszterkártya stílusa + POSZTERKÁRTYA STÍLUSA INDÍTÁSKOR KÖVETKEZŐ ELEM VISELKEDÉSE LÁTHATÓSÁG @@ -587,28 +588,28 @@ Kezdőlap elrendezésének, a tartalom láthatóságának és a poszterek viselkedésének beállítása A részletek és az epizódok képernyőinek beállításai. Egyéni katalóguscsoportok létrehozása a Kezdőlapon megjelenő mappákkal. - Integrációk + INTEGRÁCIÓK Metaadat-bővítés vezérlői Külső értékelési szolgáltatók Kísérleti felhőalapú fiókforrások - Debrid + DEBRID A Debrid támogatás kísérleti jellegű; a későbbiekben megtartható, módosítható vagy eltávolítható. Források engedélyezése Lejátszható találatok megjelenítése a csatlakoztatott fiókokból. Először adj meg egy API-kulcsot. - Fiók + FIÓK Torbox API-kulcs Add meg a Torbox API-kulcsodat. Írd be a Torbox API-kulcsot Nincs beállítva - Azonnali lejátszás + AZONNALI LEJÁTSZÁS Linkek előkészítése Az elsődleges források feloldása a lejátszás megkezdése előtt. Előkészítendő források száma Lehetőleg alacsony értéket használj. A Debrid szolgáltatások korlátozhatják (rate-limit), hogy egy adott időszakon belül hány link oldható fel. Egy film vagy epizód adatlapjának megnyitása is beleszámíthat ezekbe a korlátokba – még akkor is, ha nem nyomsz a Lejátszás gombra –, mivel a linkek előre előkészítésre kerülnek. 1 forrás %1$d forrás - Formázás + FORMÁZÁS Név sablon A források nevének megjelenítését szabályozza. Leírás sablon @@ -623,9 +624,9 @@ API-kulcs MDBList értékelések engedélyezése Külső szolgáltatóktól származó értékelések megjelenítése a részletek képernyőn - API-kulcs - Külső értékelési szolgáltatók - MDBList értékelések + API-KULCS + KÜLSŐ ÉRTÉKELÉSI SZOLGÁLTATÓK + MDBList ÉRTÉKELÉSEK Műveletek Lejátszás és mentés vezérlők. Stáb @@ -862,7 +863,7 @@ HITELOSÍTÓ ADATOK LOKALIZÁCIÓ MODULOK - TMDB adatbővítés + TMDB ADATBŐVÍTÉS A jóváhagyás után automatikusan visszairányítunk az alkalmazásba. HITELOSÍTÁS Hozzászólások @@ -1380,6 +1381,7 @@ Szöveg átlátszósága Haladó Streamek + STREAMEK Indítási és profil működés HALADÓ Stream találatok megjelenítése és jelvény URL szabályok. @@ -1421,12 +1423,12 @@ Nem sikerült elindítani a bejelentkezést. Ez a bejelentkezési mód nincs konfigurálva ebben a verzióban. Ez a kód lejárt. Próbáld újra. - Fusion stílus + FUSION STÍLUS Méret jelvények Fájlméret-jelvények megjelenítése a stream találatoknál és a lejátszó forrás-panelein. Kiegészítő logója A kiegészítő logójának és nevének megjelenítése a stream források mellett. - Megjelenítés + MEGJELENÍTÉS Jelvény pozíciója Válaszd ki, hogy a Fusion és a méret jelvények a stream kártyák felett vagy alatt jelenjenek meg. Jelvény pozíciója @@ -1638,7 +1640,7 @@ Egyéni A lentebb megadott haladó értékeid használata. Ki - iOS videó kimenet + iOS VIDEÓ KIMENET Hardveres dekóder Kiterjesztett dinamikatartomány (EDR) Alapértelmezett Metal kimeneti mód az új lejátszásokhoz. @@ -1646,12 +1648,12 @@ Hagyja, hogy az mpv alapértelmezés szerint az aktív kijelző színtartományát célozza meg. Cél alapszínek (Primaries) Cél átviteli karakterisztika - iOS hangkimenet + iOS HANGKIMENET Hangkimenet Az AudioUnit használata, amíg az AVFoundation kimenet átmenetileg le van tiltva. Kísérleti támogatás a térbeli hangzáshoz (Spatial Audio) és a többcsatornás kimenethez. A korábbi, örökölt AudioUnit kimenet használata. - Találatok kezelése + TALÁLATOK KEZELÉSE Maximális találatszám Korlátozza a megjelenő találatok számát. Találatok rendezése @@ -1896,6 +1898,7 @@ Mégse Ismeretlen torrent hiba P2P Streaming + P2P STREAMING Peer-to-peer (torrent) streamek engedélyezése Torrent statisztikák elrejtése Pufferelés, seederek, peerek és letöltési sebesség elrejtése a betöltés és lejátszás alatt @@ -1939,7 +1942,7 @@ Bekapcsolás Letöltési mappa megnyitása Nem sikerült megnyitni a letöltési mappát - Kártyák mélység-effektusa + KÁRTYÁK MÉLYSÉG-EFFEKTUSA Megvilágított felső élet és lágy fényt ad a képkártyáknak a finom mélységérzet érdekében. Mélység-effektus engedélyezése Élvilágítás diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 0c79d268d..7aa8c360c 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -404,6 +404,7 @@ Lisensi & Atribusi Rating MDBList Halaman Detail + HALAMAN DETAIL Notifikasi Pemutaran Plugin @@ -539,8 +540,8 @@ KATALOG KATALOG & KOLEKSI KOLEKSI - Tata Letak Beranda - Katalog Hero + TATA LETAK BERANDA + KATALOG HERO %1$d dari %2$d dipilih Tampilkan Bagian Hero Tampilkan karousel hero di bagian atas beranda. @@ -554,7 +555,7 @@ Sembunyikan nilai Pemutar, subtitle, dan putar otomatis Radius Sudut - Gaya Kartu Poster + GAYA KARTU POSTER Lebar Kustom Sesuaikan lebar kartu dan radius sudut. @@ -589,7 +590,7 @@ Urutkan semua item berdasarkan yang terbaru Gaya Streaming Item yang sudah rilis lebih dulu, yang mendatang di akhir - Gaya Kartu Poster + GAYA KARTU POSTER SAAT DIBUKA PERILAKU BERIKUTNYA VISIBILITAS @@ -611,11 +612,11 @@ 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 + INTEGRASI Kontrol pengayaan metadata Penyedia rating eksternal Hubungkan akun untuk akses link dan pustaka cloud - Layanan Debrid + LAYANAN DEBRID Integrasi ini masih bersifat eksperimental dan dapat dipertahankan, diubah, atau dihapus sewaktu-waktu. Pustaka Cloud Telusuri dan mainkan file yang tersedia di akun Anda. @@ -624,7 +625,7 @@ Uraikan dengan Pilih akun layanan debrid mana untuk menangani link yang dapat dimainkan. Hubungkan akun terlebih dahulu. - Akun + AKUN Hubungkan akun %1$s Anda. Hubungkan akun %1$s Anda melalui browser. %1$s API Key @@ -644,14 +645,14 @@ Tidak dapat memulai sign-in. Metode sign-in ini tidak dikonfigurasi dalam rilis ini. Kode ini telah kedaluwarsa. Coba lagi. - Persiapan Tautan + PERSIAPAN TAUTAN Menyiapkan tautan Siapkan sumber video sebelum mulai pemutaran dimulai. Jumlah sumber yang disiapkan Gunakan jumlah sekecil mungkin. Penyedia layanan Debrid bisa saja membatasi seberapa banyak tautan yang dapat diurai dalam jangka waktu tertentu. Membuka film atau episode serial dihitung dalam batas tersebut meskipun Anda tidak menonton, karena tautan tetap disiapkan terlebih dahulu. 1 sumber %1$d sumber - Format + FORMAT Pola Nama Mengatur bagaimana hasil sumber ditampilkan. Pola Deskripsi @@ -666,9 +667,9 @@ Kunci API Aktifkan Rating MDBList Ambil rating dari penyedia eksternal di layar detail metadata - Kunci API - Penyedia rating eksternal - Rating MDBList + KUNCI API + PENYEDIA RATING EKSTERNAL + RATING MDBList Tindakan Kontrol putar dan simpan. Pemeran @@ -928,7 +929,7 @@ KREDENSIAL LOKALISASI MODUL - Pengayaan TMDB + PENGAYAAN TMDB Setelah disetujui, Anda akan diarahkan kembali secara otomatis. AUTENTIKASI Komentar diff --git a/composeApp/src/commonMain/composeResources/values-in/strings.xml b/composeApp/src/commonMain/composeResources/values-in/strings.xml index b56c75ce8..1ee5d1477 100644 --- a/composeApp/src/commonMain/composeResources/values-in/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-in/strings.xml @@ -404,6 +404,7 @@ Lisensi & Atribusi Rating MDBList Halaman Detail + HALAMAN DETAIL Notifikasi Pemutaran Plugin @@ -539,8 +540,8 @@ KATALOG KATALOG & KOLEKSI KOLEKSI - Tata Letak Beranda - Katalog Hero + TATA LETAK BERANDA + KATALOG HERO %1$d dari %2$d dipilih Tampilkan Bagian Hero Tampilkan karousel hero di bagian atas beranda. @@ -554,7 +555,7 @@ Sembunyikan nilai Pemutar, subtitle, dan putar otomatis Radius Sudut - Gaya Kartu Poster + GAYA KARTU POSTER Lebar Kustom Sesuaikan lebar kartu dan radius sudut. @@ -589,7 +590,7 @@ Urutkan semua item berdasarkan yang terbaru Gaya Streaming Item yang sudah rilis lebih dulu, yang mendatang di akhir - Gaya Kartu Poster + GAYA KARTU POSTER SAAT DIBUKA PERILAKU BERIKUTNYA VISIBILITAS @@ -611,11 +612,11 @@ 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 + INTEGRASI Kontrol pengayaan metadata Penyedia rating eksternal Hubungkan akun untuk akses link dan pustaka cloud - Layanan Debrid + LAYANAN DEBRID Integrasi ini masih bersifat eksperimental dan dapat dipertahankan, diubah, atau dihapus sewaktu-waktu. Pustaka Cloud Telusuri dan mainkan file yang tersedia di akun Anda. @@ -624,7 +625,7 @@ Uraikan dengan Pilih akun layanan debrid mana untuk menangani link yang dapat dimainkan. Hubungkan akun terlebih dahulu. - Akun + AKUN Hubungkan akun %1$s Anda. Hubungkan akun %1$s Anda melalui browser. %1$s API Key @@ -644,14 +645,14 @@ Tidak dapat memulai sign-in. Metode sign-in ini tidak dikonfigurasi dalam rilis ini. Kode ini telah kedaluwarsa. Coba lagi. - Persiapan Tautan + PERSIAPAN TAUTAN Menyiapkan tautan Siapkan sumber video sebelum mulai pemutaran dimulai. Jumlah sumber yang disiapkan Gunakan jumlah sekecil mungkin. Penyedia layanan Debrid bisa saja membatasi seberapa banyak tautan yang dapat diurai dalam jangka waktu tertentu. Membuka film atau episode serial dihitung dalam batas tersebut meskipun Anda tidak menonton, karena tautan tetap disiapkan terlebih dahulu. 1 sumber %1$d sumber - Format + FORMAT Pola Nama Mengatur bagaimana hasil sumber ditampilkan. Pola Deskripsi @@ -666,9 +667,9 @@ Kunci API Aktifkan Rating MDBList Ambil rating dari penyedia eksternal di layar detail metadata - Kunci API - Penyedia rating eksternal - Rating MDBList + KUNCI API + PENYEDIA RATING EKSTERNAL + RATING MDBList Tindakan Kontrol putar dan simpan. Pemeran @@ -928,7 +929,7 @@ KREDENSIAL LOKALISASI MODUL - Pengayaan TMDB + PENGAYAAN TMDB Setelah disetujui, Anda akan diarahkan kembali secara otomatis. AUTENTIKASI Komentar diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 17b0e5b55..fc0992c67 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -236,6 +236,7 @@ Integrazioni Valutazioni MDBList Schermata Meta + SCHERMATA META Notifiche Riproduzione Plugin @@ -624,7 +625,7 @@ Illimitato Riproduzione Tunneled Abilita la riproduzione tunneled per una minore latenza nella sincronizzazione audio/video. - Uscita Audio iOS + USCITA AUDIO iOS Prova prima AVFoundation, altrimenti passa ad AudioUnit. Usa l'uscita legacy AudioUnit. Decodifica hardware @@ -1331,7 +1332,7 @@ Prediligi le miniature dell'episodio quando disponibili. Prediligi miniature episodio in Inizia a guardare Connetti gli account per i link e l'accesso alla libreria - Servizi connessi + SERVIZI CONNESSI Queste integrazioni sono sperimentali e potrebbero essere mantenute, modificate o rimosse in seguito. Libreria cloud Sfoglia e riproduci i file già presenti nei tuoi account connessi. @@ -1340,7 +1341,7 @@ Risolvi con Scegli quale account connesso gestisce i link riproducibili. Connetti prima un account. - Account + ACCOUNT Connetti il tuo account %1$s. Collega il tuo account %1$s nel browser. Chiave API %1$s @@ -1360,26 +1361,26 @@ Impossibile avviare l'accesso. Questo metodo di accesso non è configurato in questa build. Questo codice è scaduto. Riprova. - Preparazione dei link + PREPARAZIONE DEI LINK Prepara i link Risolvi i link riproducibili prima dell'inizio della riproduzione. Link da preparare Usa un numero inferiore quando possibile. I servizi connessi potrebbero limitare la frequenza dei link che possono essere risolti in un determinato periodo di tempo. L'apertura di un film o di un episodio può essere conteggiata ai fini di tali limiti anche se non premi Guarda, poiché i link vengono preparati in anticipo. 1 link %1$d link - Formattazione + FORMATTAZIONE Modello del nome Controlla come appaiono i nomi dei risultati. Modello della descrizione Controlla i metadati mostrati sotto ogni risultato. Ripristina formattazione Ripristina la formattazione predefinita dei risultati. - Stile Fusion + STILE FUSION Badge della dimensione Mostra il badge della dimensione nei risultati stream e nei pannelli origine del player. Logo Addon Mostra il logo e il nome dell'addon accanto alle sorgenti degli stream. - Display + DISPLAY Posizione badge Scegli se i badge Fusion e quelli della dimensione appaiono sopra o sotto le schede stream. Posizione badge @@ -1521,6 +1522,7 @@ Raggruppa i provider dei plugin per repository Mostra negli stream un solo provider per repository invece che uno per sorgente. Streaming P2P + STREAMING P2P Consenti lo streaming peer-to-peer (torrent) Nascondi le statistiche torrent Nascondi buffer, seeds, peers e velocità download durante il caricamento e la riproduzione @@ -1550,6 +1552,7 @@ Termini Impossibile caricare le righe dei sottotitoli Flussi + FLUSSI DIAGNOSTICA CACHE Report arresti anomali Sentry @@ -1702,10 +1705,10 @@ Personalizzato Usa i valori avanzati riportati sotto. Disattivato - Uscita video iOS + USCITA VIDEO iOS Uscita audio Supporto sperimentale per Audio Spaziale e uscita multicanale. - Gestione risultati + GESTIONE RISULTATI Risultati massimi Limita il numero di risultati visualizzati. Ordina risultati diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 885e1c81c..a432e4bb4 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -426,12 +426,14 @@ ライセンスと帰属 MDBList評価 詳細ページ + 詳細ページ 通知 再生 プラグイン ポスターカードスタイル 設定 ストリーム + ストリーム サポーターとコントリビューター TMDB補完 Trakt @@ -694,7 +696,7 @@ 各結果に表示されるメタデータを制御します。空白の場合は元の結果詳細を使用します。 フォーマットをリセット 結果のフォーマットをデフォルトに戻します。 - Fusionスタイル + FUSIONスタイル サイズバッジ ストリーム結果とプレーヤーのソースパネルにファイルサイズバッジを表示します。 アドオンロゴ @@ -1911,6 +1913,7 @@ キャンセル 不明なトレントエラー P2Pストリーミング + P2Pストリーミング ピアツーピア(トレント)ストリームを許可する トレント統計を非表示 読み込み中および再生中のバッファ・シード数・ピア数・ダウンロード速度を非表示にする diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 9957c3dbc..78a1c2140 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -392,6 +392,7 @@ Lisenser & attribusjon MDBList-vurderinger Detaljside + DETALJSIDE Varsler Avspilling Plugins @@ -523,8 +524,8 @@ KATALOGER KATALOGER & SAMLINGER SAMLINGER - Hjemmeoppsett - Hero-kataloger + HJEMMEOPPSETT + HERO-KATALOGER %1$d av %2$d valgt Vis hero-seksjon Vis hero-karusell øverst på hjemskjermen. @@ -538,7 +539,7 @@ Skjul verdi Avspiller, undertekster og auto-play Hjørneradius - Plakatstil + PLAKATSTIL Bredde Tilpasset Juster kortbredde og hjørneradius. @@ -567,13 +568,13 @@ Slør usette i Fortsett å se Inkluder kommende episoder i Fortsett å se før de sendes. Vis ikke-sendte neste episoder - SORTERINGSREKKefølge + SORTERINGSREKKEFØLGE Sorteringsrekkefølge Standard Sorter alle etter nyhet Streaming-stil Utgitt først, kommende til slutt - Plakatstil + PLAKATSTIL VED OPPSTART NESTE OPPFØRSEL SYNLIGHET @@ -595,11 +596,11 @@ Juster hjemmeoppsett, synlighet og plakat-oppførsel. Innstillinger for detalj- og episodeskjermer. Opprett egne kataloggrupperinger med mapper vist på hjem. - Integrasjoner + INTEGRASJONER Metadata-berikelse-kontroller Eksterne vurderingsleverandører Koble til kontoer for lenker og bibliotektilgang - Tilkoblede tjenester + TILKOBLEDE TJENESTER Disse integrasjonene er eksperimentelle og kan endres eller fjernes senere. Skybibliotek Bla gjennom og spill filer som allerede finnes i tilkoblede kontoer. @@ -608,7 +609,7 @@ Løs med Velg hvilken tilkoblet konto som håndterer spillbare lenker. Koble til en konto først. - Kontoer + KONTOER Koble til %1$s-kontoen din. Koble til %1$s-kontoen din i nettleseren. %1$s API-nøkkel @@ -627,13 +628,13 @@ Kunne ikke starte innlogging. Denne innloggingsmetoden er ikke konfigurert i denne versjonen. Denne koden er utløpt. Prøv igjen. - Lenkeforberedelse + LENKEFORBEREDELSE Forbered lenker Løs spillbare lenker før avspilling starter. Lenker å forberede 1 lenke %1$d lenker - Formatering + FORMATERING Navnemal Styrer hvordan resultatnavn vises. Beskrivelsesmal @@ -646,9 +647,9 @@ API-nøkkel Aktiver MDBList-vurderinger Hent vurderinger fra eksterne leverandører på detaljsiden - API-nøkkel - Eksterne vurderingsleverandører - MDBList-vurderinger + API-NØKKEL + EKSTERNE VURDERINGSLEVERANDØRER + MDBList-VURDERINGER Handlinger Avspillings- og lagringskontroller. Rollebesetning @@ -691,7 +692,7 @@ Send lokale varsler når en ny episode for en lagret serie blir tilgjengelig. Systemvarsler er deaktivert for Nuvio. Skru dem på for å motta varsler. %1$d utgivelsesvarsler er planlagt på denne enheten. - VARSler + VARSLER TEST Send testvarsel Sender testvarsel… @@ -885,7 +886,7 @@ LEGITIMASJONER LOKALISERING MODULER - TMDB-berikelse + TMDB-BERIKELSE Etter godkjenning blir du automatisk sendt tilbake. AUTENTISERING Kommentarer diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index c2fdc267f..fed9fdde4 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -426,12 +426,14 @@ Licenties en attributie MDBList-beoordelingen Detailpagina + DETAILPAGINA Meldingen Afspelen Plug-ins Posterkaartstijl Instellingen Streams + STREAMS Ondersteuners en bijdragers TMDB-verrijking Trakt @@ -591,8 +593,8 @@ CATALOGI CATALOGI EN COLLECTIES COLLECTIES - Startschermindeling - Hero-catalogi + STARTSCHERMINDELING + HERO-CATALOGI %1$d van %2$d geselecteerd Herosectie tonen Toon herocarrousel bovenaan de startpagina. @@ -606,7 +608,7 @@ Waarde verbergen Speler, ondertitels en automatisch afspelen Hoekradius - Posterkaartstijl + POSTERKAARTSTIJL Breedte Aangepast Pas kaartbreedte en hoekradius aan. @@ -641,7 +643,7 @@ Sorteer alle items op recentheid Streamingstijl Uitgebrachte items eerst, aankomende items achteraan - Posterkaartstijl + POSTERKAARTSTIJL BIJ OPSTARTEN GEDRAG VOLGENDE AFLEVERING ZICHTBAARHEID @@ -665,11 +667,11 @@ Pas startschermindeling, zichtbaarheid van inhoud en postergedrag aan Instellingen voor de detail- en afleveringsschermen. Maak aangepaste catalogusgroepen met mappen die op Home worden getoond. - Integraties + INTEGRATIES Bediening voor metadataverrijking Externe beoordelingsproviders Verbind accounts voor links en bibliotheektoegang - Verbonden diensten + VERBONDEN DIENSTEN Deze integraties zijn experimenteel en kunnen later behouden, gewijzigd of verwijderd worden. Cloudbibliotheek Blader door en speel bestanden die al in je verbonden accounts staan. @@ -678,7 +680,7 @@ Oplossen met Kies welk verbonden account afspeelbare links afhandelt. Verbind eerst een account. - Accounts + ACCOUNTS Verbind je %1$s-account. Koppel je %1$s-account in de browser. %1$s API-sleutel @@ -698,26 +700,26 @@ Kan inloggen niet starten. Deze inlogmethode is niet geconfigureerd in deze build. Deze code is verlopen. Probeer het opnieuw. - Voorbereiding van links + VOORBEREIDING VAN LINKS Links voorbereiden Los afspeelbare links op voordat het afspelen begint. Voor te bereiden links Gebruik indien mogelijk een lager aantal. Verbonden diensten kunnen het aantal links dat binnen een bepaalde periode kan worden opgelost, beperken. Het openen van een film of aflevering kan meetellen voor die limieten, zelfs als je niet op Afspelen drukt, omdat de links van tevoren worden voorbereid. 1 link %1$d links - Opmaak + OPMAAK Naamsjabloon Bepaalt hoe resultaatnamen worden weergegeven. Laat leeg om de originele resultaatnaam te gebruiken. Beschrijvingssjabloon Bepaalt de metadata die onder elk resultaat wordt getoond. Laat leeg om de originele resultaatdetails te gebruiken. Opmaak resetten Herstel de standaard resultaatopmaak. - Fusion-stijl + FUSION-STIJL Bestandsgrootte-badges Toon badges met bestandsgrootte in streamresultaten en spelerbronpanelen. Addon-logo Toon addon-logo en -naam naast streambronnen. - Weergave + WEERGAVE Badgepositie Kies of Fusion- en groottebadges boven of onder streamkaarten verschijnen. Badgepositie @@ -752,9 +754,9 @@ API-sleutel MDBList-beoordelingen inschakelen Haal beoordelingen op van externe providers op het detailscherm van metadata - API-sleutel - Externe beoordelingsproviders - MDBList-beoordelingen + API-SLEUTEL + EXTERNE BEOORDELINGSPROVIDERS + MDBList-BEOORDELINGEN Acties Bedieningselementen voor afspelen en opslaan. Cast @@ -1050,7 +1052,7 @@ INLOGGEGEVENS LOKALISATIE MODULES - TMDB-verrijking + TMDB-VERRIJKING Na goedkeuring word je automatisch teruggeleid. AUTHENTICATIE Reacties @@ -1659,7 +1661,7 @@ Gebruik je geavanceerde waarden hieronder. Uit - iOS-video-uitvoer + iOS-VIDEO-UITVOER Hardwaredecoder Uitgebreid dynamisch bereik Standaard Metal-uitvoermodus voor nieuwe afspeelsessies. @@ -1668,13 +1670,13 @@ Doelprimairen Doeloverdracht - iOS-audio-uitvoer + iOS-AUDIO-UITVOER Audio-uitvoer Gebruik AudioUnit terwijl AVFoundation-uitvoer tijdelijk is uitgeschakeld. Experimentele ondersteuning voor Spatial Audio en meerkanaals uitvoer. Gebruik de verouderde AudioUnit-uitvoer. - Resultaatbeheer + RESULTAATBEHEER Max. resultaten Beperk het aantal weergegeven resultaten. Resultaten sorteren @@ -1942,6 +1944,7 @@ Annuleren Onbekende torrentfout P2P-streaming + P2P-STREAMING Peer-to-peer (torrent) streams toestaan Torrentstatistieken verbergen Verberg buffer, seeds, peers en downloadsnelheid tijdens laden en afspelen diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 012ba9ae2..09aaa5fa5 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -404,6 +404,7 @@ Licencje i atrybucje Oceny MDBList Ekran metadanych + EKRAN METADANYCH Powiadomienia Odtwarzanie Wtyczki @@ -589,6 +590,9 @@ Sortuj wszystkie elementy według czasu Styl streamingowy Wydane najpierw, nadchodzące na końcu + Oddzielny rząd Nadchodzące + Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące + Nadchodzące STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO @@ -615,7 +619,7 @@ Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. Eksperymentalne źródła z kont chmurowych - Debrid + DEBRID Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. Biblioteka w chmurze Przeglądaj i odtwarzaj pliki z połączonych kont. @@ -624,7 +628,7 @@ Rozwiązuj przez Wybierz, które połączone konto obsługuje odtwarzalne linki. Najpierw dodaj klucz API. - Konto + KONTO Połącz swoje konto %1$s. Połącz konto %1$s w przeglądarce. Połącz swoje konto Torbox. @@ -645,14 +649,14 @@ Nie udało się rozpocząć logowania. Ta metoda logowania nie jest skonfigurowana w tej wersji. Ten kod wygasł. Spróbuj ponownie. - Natychmiastowe odtwarzanie + NATYCHMIASTOWE ODTWARZANIE Przygotuj linki Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. Źródła do przygotowania Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. 1 źródło %1$d źródeł - Formatowanie + FORMATOWANIE Szablon nazwy Kontroluje sposób wyświetlania nazw źródeł. Szablon opisu @@ -1468,7 +1472,7 @@ Web Logo dodatku Pokazuj logo i nazwę dodatku obok źródeł. - Wyświetlanie + WYŚWIETLANIE Język oryginalny Wymaga włączonego wzbogacania TMDB Język urządzenia @@ -1543,6 +1547,7 @@ Nie udało się załadować linii napisów Zaawansowane Źródła + ŹRÓDŁA Uruchamianie i zachowanie profilu. ZAAWANSOWANE Wyświetlanie wyników źródeł i reguły badge URL. @@ -1730,7 +1735,7 @@ Pokazuj tylko wybrane rozdzielczości. Wymagane tagi wizualne Wymagaj DV, HDR, 10bit, IMAX, SDR i podobnych tagów. - Zarządzanie wynikami + ZARZĄDZANIE WYNIKAMI Dowolne %1$d wybranych %1$dGB+ @@ -1767,6 +1772,7 @@ Ukryj statystyki torrenta Zezwalaj na strumienie peer-to-peer (torrent) Strumieniowanie P2P + STRUMIENIOWANIE P2P Nieprawidłowy klucz API lub błąd połączenia Nieprawidłowy klucz API lub błąd połączenia Wyjście audio @@ -1774,7 +1780,7 @@ Użyj AudioUnit, gdy wyjście AVFoundation jest tymczasowo wyłączone. Eksperymentalna obsługa Spatial Audio i wielokanałowego wyjścia. Wyjście audio - Wyjście audio iOS + WYJŚCIE AUDIO iOS Wskazówka koloru wyświetlacza Pozwól mpv celować w aktywną przestrzeń kolorów wyświetlacza. Rozszerzony zakres dynamiczny @@ -1785,7 +1791,7 @@ Docelowe kolory podstawowe Docelowy transfer Docelowy transfer - Wyjście wideo iOS + WYJŚCIE WIDEO iOS Gesty dotykowe Pozwól na przesunięcia i podwójne dotknięcia na powierzchni odtwarzacza do przewijania, regulacji jasności lub głośności. Wprowadź URL JSON badge. @@ -1797,7 +1803,7 @@ Pozycja badge Pozycja badge Na górze - Styl Fusion + STYL FUSION URL badge musi zaczynać się od http:// lub https://. Importuj do %1$d URL JSON badge Fusion. Każdy URL można aktualizować lub usuwać osobno. Zarządzaj zaimportowanymi URL JSON badge Fusion. @@ -1906,7 +1912,7 @@ Zawsze rozwinięty Zawsze kompaktowy Klasyczny - Efekt głębi karty + EFEKT GŁĘBI KARTY Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi. Włącz efekt głębi Blask krawędzi diff --git a/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml b/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml index 97b5fde26..ecd2cea82 100644 --- a/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -393,6 +393,7 @@ Licenças & Atribuições Classificações do MDBList Página de Detalhes + PÁGINA DE DETALHES Notificações Reprodução Plugins @@ -524,8 +525,8 @@ CATÁLOGOS CATÁLOGOS & COLEÇÕES COLEÇÕES - Layout da Página Inicial - Catálogos do Hero + LAYOUT DA PÁGINA INICIAL + CATÁLOGOS DO HERO %1$d de %2$d selecionado(s) Mostrar Seção Hero Exibir carrossel hero no topo da página inicial. @@ -539,7 +540,7 @@ Ocultar valor Player, legendas e reprodução automática Raio dos Cantos - Estilo do Card de Pôster + ESTILO DO CARD DE PÔSTER Largura Personalizado Ajuste largura do card e raio dos cantos. @@ -574,7 +575,7 @@ Classificar todos os itens por recenticidade Estilo Streaming Itens lançados primeiro, futuros no final - Estilo do Card de Pôster + ESTILO DO CARD DE PÔSTER AO INICIAR COMPORTAMENTO DO PRÓXIMO VISIBILIDADE @@ -596,29 +597,29 @@ Ajuste layout da página inicial, visibilidade de conteúdo e comportamento de pôsteres Configurações para as telas de detalhes e episódios. Crie agrupamentos personalizados de catálogos com pastas exibidas na Página Inicial. - Integrações + INTEGRAÇÕES Controles de enriquecimento de metadados Provedores externos de classificações Fontes de contas em nuvem experimentais - Debrid + DEBRID O suporte a Debrid é experimental e pode ser mantido, alterado ou removido posteriormente. Ativar fontes Exibir resultados reproduzíveis de contas conectadas. Adicione uma chave de API primeiro. - Conta + CONTA Conecte sua conta do Torbox. Chave de API do Torbox Insira sua chave de API do Torbox. Insira a chave de API do Torbox Não definido - Reprodução Instantânea + REPRODUÇÃO INSTANTÂNEA Preparar links Resolver as primeiras fontes antes do início da reprodução. Fontes a preparar Use uma contagem menor quando possível. Os serviços de Debrid podem limitar a taxa de quantos links podem ser resolvidos em um período de tempo. Abrir um filme ou episódio pode contar para esses limites mesmo se você não pressionar Assistir, porque os links são preparados antecipadamente. 1 fonte %1$d fontes - Formatação + FORMATAÇÃO Modelo de nome Controla como os nomes das fontes aparecem. Modelo de descrição @@ -633,9 +634,9 @@ Chave de API Ativar Classificações do MDBList Buscar classificações de provedores externos na tela de detalhes de metadados - Chave de API - Provedores externos de classificações - Classificações do MDBList + CHAVE DE API + PROVEDORES EXTERNOS DE CLASSIFICAÇÕES + CLASSIFICAÇÕES DO MDBList Ações Controles de reprodução e salvamento. Elenco @@ -874,7 +875,7 @@ CREDENCIAIS LOCALIZAÇÃO MÓDULOS - Enriquecimento do TMDB + ENRIQUECIMENTO DO TMDB Após a aprovação, você será redirecionado de volta automaticamente. AUTENTICAÇÃO Comentários diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index e6719a5f5..34f96814a 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -421,12 +421,14 @@ Licenças e atribuição Classificações MDBList Página de detalhes + PÁGINA DE DETALHES Notificações Reprodução Plugins Estilo dos cartões com cartaz Definições Streams + STREAMS Apoiantes e colaboradores Enriquecimento TMDB Trakt @@ -567,8 +569,8 @@ CATÁLOGOS CATÁLOGOS E COLEÇÕES COLEÇÕES - Esquema da página inicial - Catálogos em destaque + ESQUEMA DA PÁGINA INICIAL + CATÁLOGOS EM DESTAQUE %1$d de %2$d selecionados Mostrar secção de destaques Apresenta o carrossel de destaques no topo da página inicial. @@ -582,7 +584,7 @@ Ocultar valor Leitor, legendas e reprodução automática Raio dos cantos - Estilo dos cartões com cartaz + ESTILO DOS CARTÕES COM CARTAZ Largura Personalizado Ajusta a largura dos cartões e o raio dos cantos. @@ -617,7 +619,7 @@ Ordena todos os itens pelos mais recentes Estilo de plataforma de streaming Itens já lançados primeiro, os próximos no fim - Estilo dos cartões com cartaz + ESTILO DOS CARTÕES COM CARTAZ AO INICIAR COMPORTAMENTO DO SEGUINTE VISIBILIDADE @@ -641,11 +643,11 @@ Ajusta o esquema da página inicial, a visibilidade do conteúdo e o comportamento dos cartazes. Definições para as páginas de detalhes e de episódios. Cria agrupamentos personalizados de catálogos com pastas apresentadas no Início. - Integrações + INTEGRAÇÕES Controlos de enriquecimento de metadados Fornecedores de classificações externas Liga contas para obter links e acesso à biblioteca - Serviços ligados + SERVIÇOS LIGADOS Estas integrações são experimentais e podem ser mantidas, alteradas ou removidas mais tarde. Biblioteca na nuvem Explora e reproduz ficheiros que já se encontram nas tuas contas ligadas. @@ -654,7 +656,7 @@ Resolver com Escolhe qual das contas ligadas deve gerir os links reproduzíveis. Liga uma conta primeiro. - Contas + CONTAS Liga a tua conta %1$s. Associa a tua conta %1$s no navegador. Chave API do %1$s @@ -674,21 +676,21 @@ Não foi possível iniciar o início de sessão. Este método de início de sessão não está configurado nesta versão da app. Este código expirou. Tenta novamente. - Preparação de links + PREPARAÇÃO DE LINKS Preparar links Resolve links reproduzíveis antes de a reprodução começar. Links a preparar Utiliza um número mais baixo quando possível. Os serviços ligados podem limitar a frequência com que os links são resolvidos num determinado período. Abrir um filme ou episódio pode contar para esses limites mesmo que não toques em Reproduzir, porque os links são preparados antecipadamente. 1 link %1$d links - Formatação + FORMATAÇÃO Modelo de nome Controla a forma como os nomes dos resultados aparecem. Deixa em branco para utilizar o nome original. Modelo de descrição Controla os metadados apresentados sob cada resultado. Deixa em branco para utilizar os detalhes originais. Repor formatação Restaura a formatação predefinida dos resultados. - Estilo Fusion + ESTILO FUSION Emblemas de tamanho Mostra emblemas com o tamanho do ficheiro nos resultados de streams e nos painéis de fontes do leitor. Logótipo do addon @@ -724,9 +726,9 @@ Chave API Ativar classificações do MDBList Obtém classificações de fornecedores externos na página de detalhes. - Chave API - Fornecedores de classificações externos - Classificações MDBList + CHAVE API + FORNECEDORES DE CLASSIFICAÇÕES EXTERNOS + CLASSIFICAÇÕES MDBList Ações Controlos de reprodução e gravação. Elenco @@ -996,7 +998,7 @@ CREDENCIAIS LOCALIZAÇÃO MÓDULOS - Enriquecimento TMDB + ENRIQUECIMENTO TMDB Após a aprovação, serás redirecionado de volta automaticamente. AUTENTICAÇÃO Comentários @@ -1586,7 +1588,7 @@ Personalizado Utiliza os valores avançados abaixo. Desativado - Saída de vídeo do iOS + SAÍDA DE VÍDEO DO iOS Descodificador por hardware Gama dinâmica alargada Modo de saída Metal predefinido para novas sessões de reprodução. @@ -1599,7 +1601,7 @@ Use AudioUnit enquanto a saída AVFoundation estiver temporariamente desativada. Suporte experimental para Áudio Espacial e saída multicanal. Utiliza a saída AudioUnit antiga. - Gestão dos resultados + GESTÃO DOS RESULTADOS Máximo de resultados Limita quantos resultados aparecem. Ordenar resultados @@ -1843,6 +1845,7 @@ Ativar P2P Cancelar Streaming P2P + STREAMING P2P Permitir streams ponto a ponto (torrent) Ocultar estatísticas de torrent Oculta buffer, seeds, peers e velocidade de transferência durante o carregamento e a reprodução diff --git a/composeApp/src/commonMain/composeResources/values-ro/strings.xml b/composeApp/src/commonMain/composeResources/values-ro/strings.xml index f002ef425..2ac1e5108 100644 --- a/composeApp/src/commonMain/composeResources/values-ro/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ro/strings.xml @@ -426,12 +426,14 @@ Licențe și atribuiri Evaluări MDBList Pagina de detalii + PAGINA DE DETALII Notificări Redare Module (Plugins) Stil card poster Setări Fluxuri + FLUXURI Susținători și colaboratori Îmbunătățire TMDB Trakt @@ -591,8 +593,8 @@ CATALOAGE CATALOAGE ȘI COLECȚII COLECȚII - Aspect ecran principal - Cataloage secțiune principală (Hero) + ASPECT ECRAN PRINCIPAL + CATALOAGE SECȚIUNE PRINCIPALĂ (HERO) %1$d din %2$d selectate Afișează secțiunea principală (Hero) Afișează un carusel în partea de sus a ecranului principal. @@ -606,7 +608,7 @@ Ascunde valoarea Player, subtitrări și redare automată Raza colțurilor - Stil card poster + STIL CARD POSTER Lățime Personalizat Ajustează lățimea cardului și raza colțurilor. @@ -641,7 +643,7 @@ Sortează toate elementele după recență Stil platformă de streaming Elementele lansate primele, cele viitoare la sfârșit - Stil card poster + STIL CARD POSTER LA PORNIRE COMPORTAMENT EPISODUL URMĂTOR VIZIBILITATE @@ -665,11 +667,11 @@ Ajustează aspectul ecranului principal, vizibilitatea conținutului și comportamentul posterului Setări pentru ecranele de detalii și de episoade. Creează grupări personalizate de cataloage cu dosare afișate pe ecranul principal. - Integrări + INTEGRĂRI Comenzi pentru îmbunătățirea metadatelor Furnizori externi de evaluări Conectează conturi pentru linkuri și acces la bibliotecă - Servicii conectate + SERVICII CONECTATE Aceste integrări sunt experimentale și pot fi păstrate, modificate sau eliminate ulterior. Bibliotecă în cloud Răsfoiește și redă fișierele existente deja în conturile tale conectate. @@ -678,7 +680,7 @@ Rezolvă cu Alege ce cont conectat se ocupă de linkurile redabile. Conectează mai întâi un cont. - Conturi + CONTURI Conectează contul tău %1$s. Asociază contul tău %1$s în browser. Cheie API %1$s @@ -698,26 +700,26 @@ Nu s-a putut iniția autentificarea. Această metodă de autentificare nu este configurată în acest build. Acest cod a expirat. Încearcă din nou. - Pregătire linkuri + PREGĂTIRE LINKURI Pregătește linkurile Rezolvă linkurile redabile înainte ca redarea să înceapă. Număr de linkuri de pregătit Folosește un număr cât mai mic posibil. Serviciile conectate pot limita rata de rezolvare a linkurilor într-o anumită perioadă de timp. Deschiderea unui film sau episod poate conta la aceste limite chiar dacă nu apeși pe Vizionare, deoarece linkurile sunt pregătite în avans. 1 link %1$d linkuri - Formatare + FORMATARE Șablon nume Controlează modul în care apar numele rezultatelor. Lasă gol pentru a folosi numele original al rezultatului. Șablon descriere Controlează metadatele afișate sub fiecare rezultat. Lasă gol pentru a folosi detaliile originale ale rezultatului. Resetează formatarea Restaurează formatarea implicită a rezultatelor. - Stil Fusion + STIL FUSION Insigne de dimensiune Afișează insigne cu dimensiunea fișierului în rezultatele fluxului și în panourile cu surse ale playerului. Siglă extensie Afișează sigla și numele extensiei lângă sursele fluxului. - Afișare + AFIȘARE Poziție insignă Alege dacă insignele Fusion și de dimensiune apar deasupra sau dedesubtul cardurilor de flux. Poziție insignă @@ -752,9 +754,9 @@ Cheie API Activează evaluările MDBList Preia evaluările de la furnizorii externi în ecranul cu detalii despre metadate - Cheie API - Furnizori externi de evaluări - Evaluări MDBList + CHEIE API + FURNIZORI EXTERNI DE EVALUĂRI + EVALUĂRI MDBList Acțiuni Comenzi de redare și salvare. Distribuție @@ -1050,7 +1052,7 @@ CREDENTIALE LOCALIZARE MODULE - Îmbunătățire TMDB + ÎMBUNĂTĂȚIRE TMDB După aprobare, vei fi redirecționat înapoi în mod automat. AUTENTIFICARE Comentarii @@ -1653,7 +1655,7 @@ Personalizat Folosește valorile tale avansate de mai jos. Dezactivat - Ieșire video iOS + IEȘIRE VIDEO iOS Decodor hardware Interval dinamic extins (EDR) Modul de ieșire Metal implicit pentru noile sesiuni de redare. @@ -1661,12 +1663,12 @@ Permite mpv să vizeze spațiul de culoare activ al ecranului în mod implicit. Culori primare țintă Funcție de transfer țintă - Ieșire audio iOS + IEȘIRE AUDIO iOS Ieșire audio Se folosește AudioUnit în timp ce ieșirea AVFoundation este dezactivată temporar. Suport experimental pentru Spatial Audio și ieșire multicanal. Folosește ieșirea moștenită (legacy) AudioUnit. - Gestionare rezultate + GESTIONARE REZULTATE Număr maxim de rezultate Limitează numărul de rezultate afișate. Ordonează rezultatele @@ -1911,6 +1913,7 @@ Anulează Eroare torrent necunoscută Streaming P2P + STREAMING P2P Permite fluxurile de tip peer-to-peer (torrent) Ascunde statisticile torrentului Ascunde datele despre buffer, seeders, peers și viteza de descărcare în timpul încărcării și redării diff --git a/composeApp/src/commonMain/composeResources/values-sk/strings.xml b/composeApp/src/commonMain/composeResources/values-sk/strings.xml index 9f1c75634..d25e3f608 100644 --- a/composeApp/src/commonMain/composeResources/values-sk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-sk/strings.xml @@ -416,12 +416,14 @@ Licencie a atribúcie Hodnotenia MDBList Stránka s podrobnosťami + STRÁNKA S PODROBNOSŤAMI Oznámenia Prehrávanie Pluginy Štýl karty plagátu Nastavenia Streamy + STREAMY Podporovatelia a prispievatelia Obohatenie TMDB Trakt @@ -562,8 +564,8 @@ KATALÓGY KATALÓGY A KOLEKCIE KOLEKCIE - Rozloženie domovskej obrazovky - Katalógy hero sekcie + ROZLOŽENIE DOMOVSKEJ OBRAZOVKY + KATALÓGY HERO SEKCIE %1$d z %2$d vybraných Zobraziť hero sekciu Zobraziť hero karusel v hornej časti domovskej obrazovky. @@ -577,7 +579,7 @@ Skryť hodnotu Prehrávač, titulky a automatické prehrávanie Polomer rohov - Štýl karty plagátu + ŠTÝL KARTY PLAGÁTU Šírka Vlastné Nastaviť šírku karty a polomer rohov. @@ -612,7 +614,7 @@ Zoradiť všetky položky podľa aktuálnosti Štýl streamovania Vydané položky ako prvé, nadchádzajúce na konci - Štýl karty plagátu + ŠTÝL KARTY PLAGÁTU PRI SPUSTENÍ SPRÁVANIE ĎALŠEJ EPIZÓDY VIDITEĽNOSŤ @@ -635,11 +637,11 @@ Upraviť rozloženie domovskej obrazovky, viditeľnosť obsahu a správanie plagátov Nastavenia pre obrazovky s podrobnosťami a epizódami. Vytvoriť vlastné skupiny katalógov s priečinkami zobrazenými na domovskej obrazovke. - Integrácie + INTEGRÁCIE Ovládacie prvky obohatenia metadát Poskytovatelia externých hodnotení Pripojiť účty pre odkazy a prístup ku knižnici - Prepojené služby + PREPOJENÉ SLUŽBY Tieto integrácie sú experimentálne a môžu byť zachované, zmenené alebo neskôr odstránené. Cloudová knižnica Prehliadať a prehrávať súbory, ktoré sú už vo vašich prepojených účtoch. @@ -648,7 +650,7 @@ Riešiť pomocou Vyberte, ktorý prepojený účet spracováva prehrávateľné odkazy. Najprv pripojte účet. - Účty + ÚČTY Pripojiť váš účet %1$s. Prepojiť váš účet %1$s v prehliadači. API kľúč %1$s @@ -668,26 +670,26 @@ Nepodarilo sa spustiť prihlásenie. Táto metóda prihlásenia nie je nakonfigurovaná v tejto zostave. Platnosť tohto kódu vypršala. Skúste znova. - Príprava odkazov + PRÍPRAVA ODKAZOV Pripraviť odkazy Riešiť prehrávateľné odkazy pred začatím prehrávania. Počet odkazov na prípravu Ak je to možné, použite nižší počet. Prepojené služby môžu obmedzovať počet odkazov, ktoré je možné vyriešiť za určité časové obdobie. Otvorenie filmu alebo epizódy sa môže zarátať do týchto limitov, aj keď nestlačíte Sledovať, pretože sa odkazy pripravujú vopred. 1 odkaz %1$d odkazov - Formátovanie + FORMÁTOVANIE Šablóna názvu Ovláda, ako sa zobrazujú názvy výsledkov. Nechajte prázdne, aby sa použil pôvodný názov výsledku. Šablóna popisu Ovláda metadáta zobrazené pod každým výsledkom. Nechajte prázdne, aby sa použili pôvodné podrobnosti výsledku. Obnoviť formátovanie Obnoviť predvolené formátovanie výsledkov. - Štýl Fusion + ŠTÝL FUSION Odznaky veľkosti Zobraziť odznaky veľkosti súboru vo výsledkoch streamov a paneloch zdrojov prehrávača. Logo doplnku Zobraziť logo a názov doplnku vedľa zdrojov streamov. - Zobrazenie + ZOBRAZENIE Poloha odznaku Vyberte, či sa odznaky Fusion a veľkosti zobrazujú nad alebo pod kartami streamov. Poloha odznaku @@ -722,9 +724,9 @@ API kľúč Povoliť hodnotenia MDBList Načítať hodnotenia od externých poskytovateľov na obrazovke s podrobnosťami metadát - API kľúč - Poskytovatelia externých hodnotení - Hodnotenia MDBList + API KĽÚČ + POSKYTOVATELIA EXTERNÝCH HODNOTENÍ + HODNOTENIA MDBList Akcie Ovládacie prvky prehrávania a ukladania. Obsadenie @@ -998,7 +1000,7 @@ PRIHLASOVACIE ÚDAJE LOKALIZÁCIA MODULY - Obohatenie TMDB + OBOHATENIE TMDB Po schválení budete automaticky presmerovaný späť. AUTENTIFIKÁCIA Komentáre @@ -1604,7 +1606,7 @@ Použiť vaše pokročilé hodnoty nižšie. Vypnuté - Výstup videa iOS + VÝSTUP VIDEA iOS Hardvérový dekodér Rozšírený dynamický rozsah Predvolený režim výstupu Metal pre nové relácie prehrávania. @@ -1613,13 +1615,13 @@ Cieľové primárne farby Cieľový prenos - Výstup zvuku iOS + VÝSTUP ZVUKU iOS Výstup zvuku Najprv vyskúšať AVFoundation, potom zálohovať na AudioUnit. Experimentálna podpora priestorového zvuku a viackanálového výstupu. Použiť starší výstup AudioUnit. - Správa výsledkov + SPRÁVA VÝSLEDKOV Maximálny počet výsledkov Obmedziť počet zobrazených výsledkov. Zoradiť výsledky @@ -1887,6 +1889,7 @@ Zrušiť Neznáma chyba torrentu P2P Streamovanie + P2P STREAMOVANIE Povoliť peer-to-peer (torrent) streamy Skryť štatistiky torrentu Skryť buffer, seedy, peery a rýchlosť sťahovania počas načítavania a prehrávania diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 7a6fb8396..964b49270 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -394,6 +394,7 @@ Lisanslar & Teşekkürler MDBList puanları Detay ekranı + DETAY EKRANI Bildirimler Oynatma Pluginler @@ -605,7 +606,7 @@ Detay sayfalarını TMDB görselleri, ekip bilgileri, bölüm meta verileri ve daha fazlasıyla zenginleştir. Detay sayfalarına IMDb, Rotten Tomatoes, Metacritic ve diğer dış puanları ekle. Bağlantı ve kitaplık erişimi için hesapları bağla - Bağlı Servisler + BAĞLI SERVİSLER Bu entegrasyonlar deneyseldir; gelecekte tutulabilir, değiştirilebilir veya tamamen kaldırılabilir. Bulut kitaplığı Bağlı hesaplarında zaten bulunan dosyaları gözden geçir ve oynat. @@ -614,7 +615,7 @@ Şununla çöz Oynatılabilir bağlantıları hangi bağlı hesabın işleyeceğini seç. Önce bir hesap bağla. - Hesaplar + HESAPLAR %1$s hesabını bağla. %1$s hesabını tarayıcıda eşleştir. %1$s API Anahtarı @@ -634,14 +635,14 @@ Giriş başlatılamadı. Bu giriş yöntemi bu sürümde yapılandırılmamış. Kodun süresi doldu. Tekrar dene. - Bağlantı Hazırlığı + BAĞLANTI HAZIRLIĞI Bağlantıları hazırla Oynatma başlamadan önce oynatılabilir bağlantıları çöz. Hazırlanacak bağlantı sayısı Mümkünse daha düşük bir sayı seç. Bağlı servisler, belirli bir sürede çözülebilecek bağlantı sayısını sınırlayabilir. Bir filmi veya bölümü açmak, İzle butonuna basmasan bile limitlerini etkileyebilir, çünkü bağlantılar önceden hazırlanır. 1 bağlantı %1$d bağlantı - Biçimlendirme + BİÇİMLENDİRME Ad şablonu Sonuç adlarının nasıl görüneceğini belirler. Açıklama şablonu diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index f249e04c6..c556ce299 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -425,12 +425,14 @@ Giấy phép & Ghi nhận Đánh giá MDBList Trang chi tiết + TRANG CHI TIẾT Thông báo Phát Plugin Kiểu poster Cài đặt Nguồn phát + NGUỒN PHÁT Nhà tài trợ & Cộng tác viên Bổ sung dữ liệu TMDB Trakt @@ -644,11 +646,11 @@ Tùy chỉnh bố cục Trang chủ và cách hiển thị nội dung Thiết lập trang chi tiết và danh sách tập Tạo bộ sưu tập tùy chỉnh bằng thư mục trên Trang chủ - Tích hợp + TÍCH HỢP Thiết lập dữ liệu TMDB Dịch vụ điểm đánh giá bên ngoài Kết nối tài khoản để truy cập nguồn và thư viện đám mây - Dịch vụ kết nối + DỊCH VỤ KẾT NỐI Tính năng này đang được thử nghiệm, có thể thay đổi hoặc gỡ bỏ trong tương lai Thư viện đám mây Duyệt và phát tệp trong tài khoản đã kết nối @@ -657,7 +659,7 @@ Sử dụng dịch vụ Chọn dịch vụ dùng để tạo liên kết phát Hãy kết nối tài khoản trước - Tài khoản + TÀI KHOẢN Kết nối tài khoản %1$s Liên kết tài khoản %1$s bằng trình duyệt Khóa API %1$s @@ -677,14 +679,14 @@ Đăng nhập thất bại Phương thức đăng nhập này chưa được hỗ trợ trong phiên bản hiện tại Mã đã hết hạn, vui lòng thử lại - Chuẩn bị liên kết phát + CHUẨN BỊ LIÊN KẾT PHÁT Chuẩn bị liên kết phát Xác thực liên kết trước khi bắt đầu Liên kết cần xử lý Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn "Xem", vì các liên kết đã được chuẩn bị sẵn từ trước 1 liên kết %1$d liên kết - Định dạng hiển thị + ĐỊNH DẠNG HIỂN THỊ Mẫu tên Tùy chỉnh tên hiển thị. Bỏ trống nếu dùng tên gốc Mẫu mô tả @@ -731,9 +733,9 @@ Khóa API Hiển thị đánh giá MDBList Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết - Khóa API - Nguồn điểm đánh giá - Đánh giá MDBList + KHÓA API + NGUỒN ĐIỂM ĐÁNH GIÁ + ĐÁNH GIÁ MDBList Thao tác Phát và lưu nội dung Diễn viên @@ -1031,7 +1033,7 @@ THÔNG TIN XÁC THỰC BẢN ĐỊA HÓA MÔ-ĐUN - Thông tin từ TMDB + THÔNG TIN TỪ TMDB Bạn sẽ được tự động chuyển hướng sau khi yêu cầu được duyệt XÁC THỰC Bình luận @@ -1632,7 +1634,7 @@ Tùy chỉnh Sử dụng các thiết lập nâng cao bên dưới Tắt - Đầu ra video trên iOS + ĐẦU RA VIDEO TRÊN iOS Decoder phần cứng Dải tương phản động mở rộng Cài đặt Metal mặc định khi bắt đầu phát video @@ -1640,12 +1642,12 @@ Đồng bộ không gian màu với màn hình Không gian màu mục tiêu Cấu hình truyền tải - Đầu ra âm thanh trên iOS + ĐẦU RA ÂM THANH TRÊN iOS Đầu ra âm thanh Dùng AudioUnit khi đầu ra AVFoundation tạm thời bị vô hiệu hóa Thử nghiệm: Spatial Audio và Âm thanh đa kênh Dùng AudioUnit truyền thống - Quản lý kết quả + QUẢN LÝ KẾT QUẢ Số kết quả tối đa Giới hạn số kết quả hiển thị Sắp xếp kết quả @@ -1890,6 +1892,7 @@ Hủy Lỗi torrent không xác định Phát qua P2P + PHÁT QUA P2P Cho phép phát từ nguồn torrent (P2P) Ẩn thống kê torrent Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1aef63cd2..ae50853a4 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -427,12 +427,14 @@ Licenses & Attribution MDBList Ratings Detail Page + DETAIL PAGE Notifications Playback Plugins Poster Card Style Settings Streams + STREAMS Supporters & Contributors TMDB Enrichment Trakt @@ -598,8 +600,8 @@ CATALOGS CATALOGS & COLLECTIONS COLLECTIONS - Home Layout - Hero Catalogs + HOME LAYOUT + HERO CATALOGS %1$d of %2$d selected Show Hero Section Display hero carousel at top of home. @@ -614,7 +616,7 @@ Visible Hide value Player, subtitles, and auto-play - Card Depth Effect + CARD DEPTH EFFECT Adds a lit top edge and a soft sheen to image cards for a subtle sense of depth. Enable depth effect Edge glow @@ -646,7 +648,7 @@ Cast Trailers Corner Radius - Poster Card Style + POSTER CARD STYLE Width Custom Tune card width and corner radius. @@ -681,7 +683,10 @@ Sort all items by recency Streaming Style Released items first, upcoming at the end - Poster Card Style + Separate Upcoming Row + Move unaired episodes to a separate Upcoming row + Upcoming + POSTER CARD STYLE ON LAUNCH NEXT UP BEHAVIOR VISIBILITY @@ -705,11 +710,11 @@ Adjust home layout, content visibility, and poster behavior Settings for the detail and episode screens. Create custom catalog groupings with folders shown on Home. - Integrations + INTEGRATIONS Metadata enrichment controls External ratings providers Connect accounts for links and library access - Connected Services + CONNECTED SERVICES These integrations are experimental and may be kept, changed, or removed later. Cloud library Browse and play files already in your connected accounts. @@ -718,7 +723,7 @@ Resolve with Choose which connected account handles playable links. Connect an account first. - Accounts + ACCOUNTS Connect your %1$s account. Link your %1$s account in the browser. %1$s API Key @@ -738,26 +743,26 @@ Could not start sign-in. This sign-in method is not configured in this build. This code expired. Try again. - Link Preparation + LINK PREPARATION Prepare links Resolve playable links before playback starts. Links to prepare Use a lower count when possible. Connected services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. 1 link %1$d links - Formatting + FORMATTING Name template Controls how result names appear. Leave blank to use the original result name. Description template Controls the metadata shown under each result. Leave blank to use the original result details. Reset formatting Restore default result formatting. - Fusion Style + FUSION STYLE Size badges Show file size badges in stream results and player source panels. Addon logo Show addon logo and name next to stream sources. - Display + DISPLAY Badge position Choose whether Fusion and size badges appear above or below stream cards. Badge position @@ -792,9 +797,9 @@ API Key Enable MDBList Ratings Fetch ratings from external providers in metadata detail screen - API Key - External ratings providers - MDBList Ratings + API KEY + EXTERNAL RATINGS PROVIDERS + MDBList RATINGS Actions Play and save controls. Cast @@ -1094,7 +1099,7 @@ CREDENTIALS LOCALIZATION MODULES - TMDB Enrichment + TMDB ENRICHMENT After approval, you will be redirected back automatically. AUTHENTICATION Comments @@ -1146,6 +1151,7 @@ Rotten Tomatoes TMDB Trakt + MyAnimeList Unknown Amber Crimson @@ -1611,8 +1617,19 @@ %1$s - %2$s Saved titles will appear here after you tap Save on a details screen. Your library is empty + All types + Select list + Sort library + Select type + Show horizontal shelves + Show vertical grid Couldn't load library Other + Oldest added + Recently added + Title A–Z + Title Z–A + Trakt order Cloud Saved Library @@ -1641,6 +1658,7 @@ %1$d playable files All Refresh cloud library + Search cloud library Select provider Select type Ready to play @@ -1708,7 +1726,7 @@ Use your advanced values below. Off - iOS video output + iOS VIDEO OUTPUT Hardware decoder Extended dynamic range Default Metal output mode for new playback sessions. @@ -1717,13 +1735,13 @@ Target primaries Target transfer - iOS audio output + iOS AUDIO OUTPUT Audio output Use AudioUnit while AVFoundation output is temporarily disabled. Experimental support for Spatial Audio and multichannel output. Use the legacy AudioUnit output. - Result Management + RESULT MANAGEMENT Max results Limit how many results appear. Sort results @@ -1991,15 +2009,39 @@ Cancel Unknown torrent error P2P Streaming + P2P STREAMING Allow peer-to-peer (torrent) streams Hide torrent stats Hide buffer, seeds, peers and download speed during loading and playback + Torrent profile + Soft + Balanced + Fast + Uses fewer peer connections to reduce battery and network load + Recommended balance of startup speed and connection stability + Uses more peer connections for difficult or high-bitrate torrents + Torrent cache size + No persistent cache + 2 GB + 5 GB + 10 GB + Clear torrent cache + %1$s currently used + Usage is measured after P2P starts; tap to clear cached torrent data + Clearing inactive torrent data… + Stop P2P playback before clearing the cache + Cleared %1$s of inactive torrent data + Could not clear the torrent cache %1$d seeds · %2$d peers %1$s buffered · %2$s · %3$s %1$s · %2$s %1$d peers · %2$d seeds · %3$d%% Connecting to peers… Starting P2P engine… + Fetching torrent metadata… + Preparing torrent stream… + %1$s · %2$s · %3$s + %1$s downloaded · %2$s · %3$s Failed to start torrent: %1$s Torrent error: %1$s diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ffd470e7b..dd8cc3e73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -74,6 +74,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration @@ -101,6 +102,7 @@ import com.nuvio.app.core.ui.NuvioNavBarScrollState import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder @@ -158,6 +160,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem @@ -759,13 +762,17 @@ private fun MainAppContent( onSwitchProfile: () -> Unit = {}, ) { val navBackStack = rememberNavBackStack(navigationSavedStateConfiguration, initialRoute) + val routeDisposalDecorator = remember { + RouteDisposalNavEntryDecorator { key -> + if (key is AppRoute) disposeRoute(key) + } + } val navController = remember(navBackStack, onNavigate, onGoBack, onReplace) { NuvioNavigator( backStack = navBackStack, onExternalNavigate = onNavigate, onExternalBack = onGoBack, onExternalReplace = onReplace, - onRouteRemoved = ::disposeRoute, ) } val appUpdaterController = rememberAppUpdaterController() @@ -1676,7 +1683,7 @@ private fun MainAppContent( stringResource(Res.string.compose_catalog_subtitle_library) } - val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> + val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption -> val launchId = CatalogLaunchStore.put( CatalogLaunch( title = section.displayTitle, @@ -1684,6 +1691,7 @@ private fun MainAppContent( target = CatalogTarget.Library( contentType = section.items.firstOrNull()?.type ?: "movie", sectionType = section.type, + sortOption = sortOption, ), ), ) @@ -1824,8 +1832,12 @@ private fun MainAppContent( backStack = navBackStack, modifier = Modifier.fillMaxSize(), onBack = { navController.popBackStack() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + routeDisposalDecorator, + ), sharedTransitionScope = this@SharedTransitionLayout, - entryProvider = entryProvider { + entryProvider = entryProvider { entry { PlatformBackHandler( enabled = true, @@ -3260,6 +3272,13 @@ private fun MainAppContent( }, ) } + }.let { provider -> + { key -> + routeDisposalDecorator.register( + key = key, + entry = provider(key), + ) + } }, ) } @@ -3384,6 +3403,7 @@ private fun MainAppContent( imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl), title = item.title, subtitle = localizedContinueWatchingSubtitle(item), + depthSurface = NuvioCardDepthSurface.ContinueWatching, anchor = anchor, actions = buildList { if (showDetailsOption) { @@ -3605,7 +3625,7 @@ private fun AppTabHost( onPosterLongClick: ((MetaPreview) -> Unit)? = null, onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 68fa9f0af..f00ca59ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -12,6 +12,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerSettingsRepository @@ -56,6 +57,7 @@ internal object LocalAccountDataCleaner { HomeCatalogSettingsRepository.clearLocalState() MetaScreenSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() + LibraryDisplaySettingsRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() CollectionMobileSettingsRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index f81af882a..703b88c48 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -427,7 +427,7 @@ object SyncManager { continue } - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() val traktAuthenticated = TraktAuthRepository.isAuthenticated.value diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 34bb8ee3d..9629e7f2e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -129,6 +129,7 @@ fun NuvioPosterZoomActionOverlay( title: String, subtitle: String?, isWatched: Boolean = false, + depthSurface: NuvioCardDepthSurface = NuvioCardDepthSurface.Posters, anchor: PosterZoomAnchor?, actions: List, hazeState: HazeState, @@ -136,6 +137,7 @@ fun NuvioPosterZoomActionOverlay( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio + val previewShape = RoundedCornerShape(PosterZoomFinalCornerRadius) val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -385,7 +387,11 @@ fun NuvioPosterZoomActionOverlay( shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale)) clip = true } - .background(tokens.colors.surfaceCard), + .background(tokens.colors.surfaceCard) + .nuvioCardDepth( + shape = previewShape, + surface = depthSurface, + ), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index 2d2c4909f..c69a3ea86 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.TmdbCollectionSourceResolver import com.nuvio.app.features.collection.catalogRouteKey import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.sortLibraryItems import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.filterReleasedItems @@ -88,10 +89,16 @@ object CatalogRepository { runCatching { val target = request.target as CatalogTarget.Library LibraryRepository.ensureLoaded() - LibraryRepository.uiState.value.sections + val libraryState = LibraryRepository.uiState.value + val items = libraryState.sections .firstOrNull { it.type == target.sectionType } ?.items .orEmpty() + sortLibraryItems( + items = items, + selected = target.sortOption, + sourceMode = libraryState.sourceMode, + ) .map { it.toMetaPreview() } .let(::dedupeCatalogItems) }.fold( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index c11312d5e..816c175d0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -50,7 +50,9 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -304,6 +306,10 @@ private fun CatalogPosterTile( .aspectRatio(item.posterShape.catalogAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt index e3ae93036..ae9abb298 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.catalog +import com.nuvio.app.features.library.LibrarySortOption import kotlinx.serialization.Serializable sealed interface CatalogTarget { @@ -17,6 +18,7 @@ sealed interface CatalogTarget { data class Library( override val contentType: String, val sectionType: String, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, ) : CatalogTarget { override val supportsPagination: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 5f335f2ad..44911c48e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -76,6 +76,7 @@ import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder @@ -1339,6 +1340,7 @@ fun MetaDetailsScreen( title = selectedEpisode.title, subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel, isWatched = isSelectedEpisodeWatched, + depthSurface = NuvioCardDepthSurface.EpisodeCards, anchor = zoomAnchor, actions = buildList { add( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index bbf2e7233..65da8ae65 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -50,6 +50,7 @@ import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_AUDIENCE import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_IMDB import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_LETTERBOXD import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_METACRITIC +import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_MAL import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TMDB import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TOMATOES import com.nuvio.app.features.mdblist.MdbListMetadataService.PROVIDER_TRAKT @@ -358,22 +359,6 @@ private val ratingVisuals = listOf( valueColor = Color(0xFF01B4E4), format = ::formatWhole, ), - RatingVisuals( - source = PROVIDER_TOMATOES, - displayName = "Rotten Tomatoes", - logo = Res.drawable.rating_rotten_tomatoes, - logoWidth = 16.dp, - valueColor = Color(0xFFFA320A), - format = ::formatPercent, - ), - RatingVisuals( - source = PROVIDER_METACRITIC, - displayName = "Metacritic", - logo = Res.drawable.rating_metacritic, - logoWidth = 16.dp, - valueColor = Color(0xFFFFCC33), - format = ::formatWhole, - ), RatingVisuals( source = PROVIDER_TRAKT, displayName = "Trakt", @@ -390,6 +375,22 @@ private val ratingVisuals = listOf( valueColor = Color(0xFF00E054), format = ::formatOneDecimal, ), + RatingVisuals( + source = PROVIDER_MAL, + displayName = "MyAnimeList", + logo = Res.drawable.rating_mal, + logoWidth = 16.dp, + valueColor = Color(0xFF2E51A2), + format = ::formatOneDecimal, + ), + RatingVisuals( + source = PROVIDER_TOMATOES, + displayName = "Rotten Tomatoes", + logo = Res.drawable.rating_rotten_tomatoes, + logoWidth = 16.dp, + valueColor = Color(0xFFFA320A), + format = ::formatPercent, + ), RatingVisuals( source = PROVIDER_AUDIENCE, displayName = runBlocking { getString(Res.string.rating_audience_score) }, @@ -398,6 +399,14 @@ private val ratingVisuals = listOf( valueColor = Color(0xFFFA320A), format = ::formatPercent, ), + RatingVisuals( + source = PROVIDER_METACRITIC, + displayName = "Metacritic", + logo = Res.drawable.rating_metacritic, + logoWidth = 16.dp, + valueColor = Color(0xFFFFCC33), + format = ::formatWhole, + ), ) private fun formatOneDecimal(value: Double): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 31676eee1..af37817bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.home import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,6 +15,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository @@ -45,6 +48,7 @@ import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding +import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.WatchProgressSource @@ -58,11 +62,13 @@ import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching @@ -132,6 +138,7 @@ fun HomeScreen( }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val continueWatchingListState = rememberLazyListState() + val upcomingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() @@ -286,6 +293,7 @@ fun HomeScreen( val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } + var hasUserScrolledUpcoming by remember(activeProfileId) { mutableStateOf(false) } LaunchedEffect(activeProfileId, continueWatchingListState) { snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> @@ -293,6 +301,12 @@ fun HomeScreen( } } + LaunchedEffect(activeProfileId, upcomingListState) { + snapshotFlow { upcomingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledUpcoming = true + } + } + var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { mutableStateOf>>(emptyMap()) } @@ -421,7 +435,7 @@ fun HomeScreen( ) } - val continueWatchingItems = remember( + val allContinueWatchingItems = remember( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, @@ -439,6 +453,17 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + val (continueWatchingItems, upcomingItems) = remember( + allContinueWatchingItems, + continueWatchingPreferences.sortMode, + ) { + splitUpcomingItems( + items = allContinueWatchingItems, + mode = continueWatchingPreferences.sortMode, + ) + } + val hasContinueWatchingRows = continueWatchingItems.isNotEmpty() || upcomingItems.isNotEmpty() + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { snapshotFlow { @@ -455,6 +480,22 @@ fun HomeScreen( } } } + LaunchedEffect(activeProfileId, upcomingItems.isNotEmpty(), hasUserScrolledUpcoming) { + if (!hasUserScrolledUpcoming && upcomingItems.isNotEmpty()) { + snapshotFlow { + upcomingListState.firstVisibleItemIndex to + upcomingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledUpcoming && + !upcomingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + upcomingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } @@ -817,7 +858,7 @@ fun HomeScreen( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, - continueWatchingItems.isNotEmpty(), + hasContinueWatchingRows, continueWatchingLayout, posterCardStyle.widthDp, homeSettingsUiState.hideCatalogUnderline, @@ -826,7 +867,7 @@ fun HomeScreen( if ( maxWidth.value < 600f && continueWatchingPreferences.isVisible && - continueWatchingItems.isNotEmpty() + hasContinueWatchingRows ) { continueWatchingHeroViewportReserveHeight( style = continueWatchingPreferences.style, @@ -882,22 +923,17 @@ fun HomeScreen( when { !hasActiveAddons && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -908,22 +944,17 @@ fun HomeScreen( } homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) items(3) { HomeSkeletonRow( modifier = Modifier.padding(horizontal = 16.dp), @@ -933,7 +964,7 @@ fun HomeScreen( } homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && - (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) && + (!continueWatchingPreferences.isVisible || !hasContinueWatchingRows) && !hasRenderableCollectionRows -> { item { if (networkStatusUiState.isOfflineLike) { @@ -957,22 +988,17 @@ fun HomeScreen( } else -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) keyedEnabledHomeItems.forEach { keyedSettingsItem -> val settingsItem = keyedSettingsItem.value @@ -1018,8 +1044,58 @@ fun HomeScreen( } } +private fun LazyListScope.homeContinueWatchingSections( + preferences: ContinueWatchingPreferencesUiState, + continueWatchingItems: List, + upcomingItems: List, + sectionPadding: Dp, + layout: ContinueWatchingLayout, + continueWatchingListState: LazyListState, + upcomingListState: LazyListState, + onItemClick: ((ContinueWatchingItem) -> Unit)?, + onItemLongPress: ((ContinueWatchingItem) -> Unit)?, +) { + if (!preferences.isVisible) return + + if (continueWatchingItems.isNotEmpty()) { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { + HomeContinueWatchingSection( + items = continueWatchingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + sectionPadding = sectionPadding, + layout = layout, + listState = continueWatchingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } + + if (upcomingItems.isNotEmpty()) { + item(key = HOME_UPCOMING_SECTION_KEY) { + HomeContinueWatchingSection( + items = upcomingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + title = stringResource(Res.string.upcoming_section_title), + sectionPadding = sectionPadding, + layout = layout, + listState = upcomingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } +} + private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" +private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L @@ -1461,11 +1537,44 @@ internal fun buildHomeContinueWatchingItems( } return when (sortMode) { - ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.SPLIT_UPCOMING, + -> deduplicated.map(HomeContinueWatchingCandidate::item) ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) } } +/** + * Splits unaired next-up episodes into a dedicated row when [ContinueWatchingSortMode.SPLIT_UPCOMING] + * is active. The main row keeps its recency ordering, while Upcoming is ordered by the nearest + * release instant with unknown dates last. + */ +internal fun splitUpcomingItems( + items: List, + mode: ContinueWatchingSortMode, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Pair, List> { + if (mode != ContinueWatchingSortMode.SPLIT_UPCOMING) { + return items to emptyList() + } + + val (upcoming, main) = items.partition { item -> + item.isNextUp && parseReleaseDateToEpochMs(item.released) + ?.let { releaseEpochMs -> releaseEpochMs > nowEpochMs } == true + } + val sortedUpcoming = upcoming.sortedWith { first, second -> + val firstRelease = parseReleaseDateToEpochMs(first.released) + val secondRelease = parseReleaseDateToEpochMs(second.released) + when { + firstRelease == null && secondRelease == null -> 0 + firstRelease == null -> 1 + secondRelease == null -> -1 + else -> firstRelease.compareTo(secondRelease) + } + } + return main to sortedUpcoming +} + private fun applyStreamingStyleSort( candidates: List, todayIsoDate: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index da63fe5d1..e174a4923 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.collection.Collection @@ -134,7 +136,11 @@ private fun CollectionFolderCard( Card( modifier = Modifier .fillMaxWidth() - .aspectRatio(aspectRatio), + .aspectRatio(aspectRatio) + .nuvioCardDepth( + shape = shapeCorner, + surface = NuvioCardDepthSurface.Posters, + ), shape = shapeCorner, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, 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 2ad956799..37a660f3c 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 @@ -235,6 +235,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, + title: String? = null, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, listState: LazyListState = rememberLazyListState(), @@ -250,6 +251,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), + title = title, sectionPadding = sectionPadding, layout = layout, listState = listState, @@ -264,6 +266,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), + title = title, sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), listState = listState, @@ -281,6 +284,7 @@ private fun HomeContinueWatchingSectionContent( useEpisodeThumbnails: Boolean, blurNextUp: Boolean, modifier: Modifier, + title: String?, sectionPadding: Dp, layout: ContinueWatchingLayout, listState: LazyListState, @@ -296,7 +300,7 @@ private fun HomeContinueWatchingSectionContent( val displayEntries = disintegration.sync(items) NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), entries = displayEntries, modifier = modifier, headerHorizontalPadding = sectionPadding, @@ -1023,6 +1027,10 @@ private fun ContinueWatchingPosterCard( .height(layout.posterCardHeight) .clip(RoundedCornerShape(layout.cardRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) + .nuvioCardDepth( + shape = RoundedCornerShape(layout.cardRadius), + surface = NuvioCardDepthSurface.ContinueWatching, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt new file mode 100644 index 000000000..7674f250f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt @@ -0,0 +1,178 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.watching.application.WatchingState + +internal fun posterGridColumnCountForWidth(screenWidth: Dp): Int = + when { + screenWidth >= 1400.dp -> 7 + screenWidth >= 1200.dp -> 6 + screenWidth >= 1000.dp -> 5 + screenWidth >= 840.dp -> 4 + else -> 3 + } + +@Composable +internal fun PosterGridRow( + items: List, + columns: Int, + modifier: Modifier = Modifier, + watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), + onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items.forEach { item -> + PosterGridTile( + item = item, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, + modifier = Modifier.weight(1f), + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } + repeat(columns - items.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +internal fun PosterGridSkeletonRow( + columns: Int, + modifier: Modifier = Modifier, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(columns) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(0.68f) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PosterGridTile( + item: MetaPreview, + cornerRadiusDp: Int, + hideLabels: Boolean, + modifier: Modifier = Modifier, + isWatched: Boolean = false, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(item.posterShape.posterGridAspectRatio()) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = item.poster, + zoomCornerRadius = cornerRadiusDp.dp, + ), + ) { + if (item.poster != null) { + AsyncImage( + model = item.poster, + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + NuvioPosterWatchedOverlay(isWatched = isWatched) + } + if (!hideLabels) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +private fun PosterShape.posterGridAspectRatio(): Float = + when (this) { + PosterShape.Poster -> 0.68f + PosterShape.Square -> 1f + PosterShape.Landscape -> 1.2f + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt new file mode 100644 index 000000000..d7f2b7752 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt @@ -0,0 +1,251 @@ +package com.nuvio.app.features.library + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class LibraryLayoutMode { + HORIZONTAL, + VERTICAL, +} + +enum class LibrarySortOption { + DEFAULT, + ADDED_DESC, + ADDED_ASC, + TITLE_ASC, + TITLE_DESC, +} + +data class LibraryDisplaySettingsUiState( + val layoutMode: LibraryLayoutMode = LibraryLayoutMode.HORIZONTAL, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, +) + +object LibraryDisplaySettingsRepository { + private val _uiState = MutableStateFlow(LibraryDisplaySettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = LibraryDisplaySettingsUiState() + } + + fun setLayoutMode(layoutMode: LibraryLayoutMode) { + ensureLoaded() + if (_uiState.value.layoutMode == layoutMode) return + _uiState.value = _uiState.value.copy(layoutMode = layoutMode) + persist() + } + + fun setSortOption(sortOption: LibrarySortOption) { + ensureLoaded() + if (_uiState.value.sortOption == sortOption) return + _uiState.value = _uiState.value.copy(sortOption = sortOption) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + _uiState.value = decodeLibraryDisplaySettings(LibraryDisplaySettingsStorage.loadPayload()) + } + + private fun persist() { + LibraryDisplaySettingsStorage.savePayload(encodeLibraryDisplaySettings(_uiState.value)) + } +} + +internal data class LibraryVerticalEntry( + val item: LibraryItem, + val section: LibrarySection, +) + +internal data class LibraryVerticalProjection( + val availableSections: List, + val selectedSectionKey: String?, + val availableTypes: List, + val selectedType: String?, + val entries: List, +) + +internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List = + if (sourceMode == LibrarySourceMode.TRAKT) { + LibrarySortOption.entries + } else { + LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT } + } + +internal fun effectiveLibrarySortOption( + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): LibrarySortOption = + if (selected == LibrarySortOption.DEFAULT && sourceMode == LibrarySourceMode.LOCAL) { + LibrarySortOption.ADDED_DESC + } else { + selected + } + +internal fun sortLibraryItems( + items: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + when (effectiveLibrarySortOption(selected, sourceMode)) { + LibrarySortOption.DEFAULT -> items.sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_DESC -> items.sortedWith( + compareByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_ASC -> items.sortedWith( + compareBy { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_ASC -> items.sortedWith( + compareBy { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_DESC -> items.sortedWith( + compareByDescending { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + } + +internal fun sortLibrarySections( + sections: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + sections.map { section -> + section.copy(items = sortLibraryItems(section.items, selected, sourceMode)) + } + +internal fun buildLibraryVerticalProjection( + sections: List, + sourceMode: LibrarySourceMode, + selectedSectionKey: String?, + selectedType: String?, + sortOption: LibrarySortOption, +): LibraryVerticalProjection { + val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList() + val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) { + sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull() + } else { + null + } + val baseEntries = if (selectedSection != null) { + selectedSection.items.map { item -> LibraryVerticalEntry(item, selectedSection) } + } else { + sections.flatMap { section -> + section.items.map { item -> LibraryVerticalEntry(item, section) } + } + } + val deduplicatedEntries = LinkedHashMap() + baseEntries.forEach { entry -> + val key = libraryDisplayItemKey(entry.item) + if (key !in deduplicatedEntries) { + deduplicatedEntries[key] = entry + } + } + val availableTypes = deduplicatedEntries.values + .map { entry -> entry.item.type.normalizedLibraryType() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + val effectiveType = selectedType + ?.normalizedLibraryType() + ?.takeIf { it in availableTypes } + val filteredEntries = deduplicatedEntries.values.filter { entry -> + effectiveType == null || entry.item.type.normalizedLibraryType() == effectiveType + } + val entryByKey = filteredEntries.associateBy { entry -> libraryDisplayItemKey(entry.item) } + val sortedEntries = sortLibraryItems( + items = filteredEntries.map { entry -> entry.item }, + selected = sortOption, + sourceMode = sourceMode, + ).mapNotNull { item -> entryByKey[libraryDisplayItemKey(item)] } + + return LibraryVerticalProjection( + availableSections = availableSections, + selectedSectionKey = selectedSection?.type, + availableTypes = availableTypes, + selectedType = effectiveType, + entries = sortedEntries, + ) +} + +internal fun encodeLibraryDisplaySettings(state: LibraryDisplaySettingsUiState): String = + LibraryDisplaySettingsJson.encodeToString( + StoredLibraryDisplaySettings( + layoutMode = state.layoutMode.name, + sortOption = state.sortOption.name, + ), + ) + +internal fun decodeLibraryDisplaySettings(payload: String?): LibraryDisplaySettingsUiState { + val stored = payload + ?.takeIf { it.isNotBlank() } + ?.let { value -> + runCatching { + LibraryDisplaySettingsJson.decodeFromString(value) + }.getOrNull() + } + return LibraryDisplaySettingsUiState( + layoutMode = stored?.layoutMode + ?.let { value -> LibraryLayoutMode.entries.firstOrNull { it.name == value } } + ?: LibraryLayoutMode.HORIZONTAL, + sortOption = stored?.sortOption + ?.let { value -> LibrarySortOption.entries.firstOrNull { it.name == value } } + ?: LibrarySortOption.DEFAULT, + ) +} + +private val LibraryDisplaySettingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private val LeadingLibraryTitleArticle = Regex("^(the|an|a)\\s+", RegexOption.IGNORE_CASE) + +private fun libraryTitleSortKey(item: LibraryItem): String = + libraryTitleTieBreakKey(item) + .trim() + .replace(LeadingLibraryTitleArticle, "") + +private fun libraryTitleTieBreakKey(item: LibraryItem): String = + item.name + .ifBlank { item.id } + .lowercase() + +private fun libraryDisplayItemKey(item: LibraryItem): String = + "${item.type.normalizedLibraryType()}:${item.id.trim()}" + +private fun String.normalizedLibraryType(): String = trim().lowercase() + +@Serializable +private data class StoredLibraryDisplaySettings( + @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name, + @SerialName("sort_option") val sortOption: String = LibrarySortOption.DEFAULT.name, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt new file mode 100644 index 000000000..a1cfceaa8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.library + +internal expect object LibraryDisplaySettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 06c102f8c..cb9fbebe3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -151,7 +151,7 @@ object LibraryRepository { TraktSettingsRepository.onProfileChanged() if (!loadFromDisk(profileId)) return - TraktAuthRepository.onProfileChanged() + TraktAuthRepository.onProfileChanged(profileId) TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt new file mode 100644 index 000000000..b5fc5c467 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.library + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_filter_all_types +import nuvio.composeapp.generated.resources.library_filter_list +import nuvio.composeapp.generated.resources.library_filter_sort +import nuvio.composeapp.generated.resources.library_filter_type +import nuvio.composeapp.generated.resources.library_sort_added_asc +import nuvio.composeapp.generated.resources.library_sort_added_desc +import nuvio.composeapp.generated.resources.library_sort_title_asc +import nuvio.composeapp.generated.resources.library_sort_title_desc +import nuvio.composeapp.generated.resources.library_sort_trakt_order +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun LibrarySavedControls( + layoutMode: LibraryLayoutMode, + sourceMode: LibrarySourceMode, + sortOption: LibrarySortOption, + verticalProjection: LibraryVerticalProjection, + onSectionSelected: (String) -> Unit, + onTypeSelected: (String?) -> Unit, + onSortSelected: (LibrarySortOption) -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = availableLibrarySortOptions(sourceMode) + val allTypesLabel = stringResource(Res.string.library_filter_all_types) + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) { + val selectedSection = verticalProjection.availableSections + .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_list), + label = selectedSection?.displayTitle.orEmpty(), + selectedKey = verticalProjection.selectedSectionKey, + options = verticalProjection.availableSections.map { section -> + NuvioDropdownOption(key = section.type, label = section.displayTitle) + }, + enabled = verticalProjection.availableSections.size > 1, + onSelected = { option -> onSectionSelected(option.key) }, + ) + } + + if (layoutMode == LibraryLayoutMode.VERTICAL) { + val typeOptions = buildList { + add(NuvioDropdownOption(key = "", label = allTypesLabel)) + addAll( + verticalProjection.availableTypes.map { type -> + NuvioDropdownOption(key = type, label = localizedMediaTypeLabel(type)) + }, + ) + } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_type), + label = verticalProjection.selectedType + ?.let(::localizedMediaTypeLabel) + ?: allTypesLabel, + selectedKey = verticalProjection.selectedType.orEmpty(), + options = typeOptions, + enabled = typeOptions.size > 1, + onSelected = { option -> onTypeSelected(option.key.ifBlank { null }) }, + ) + } + + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_sort), + label = librarySortOptionLabel(sortOption), + selectedKey = sortOption.name, + options = sortOptions.map { option -> + NuvioDropdownOption(key = option.name, label = librarySortOptionLabel(option)) + }, + enabled = sortOptions.size > 1, + onSelected = { option -> + LibrarySortOption.entries + .firstOrNull { it.name == option.key } + ?.let(onSortSelected) + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalContent( + projection: LibraryVerticalProjection, + columns: Int, + watchedKeys: Set, + fullyWatchedSeriesKeys: Set, + onPosterClick: ((LibraryItem) -> Unit)?, + onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, +) { + items( + items = projection.entries.chunked(columns), + key = { rowEntries -> + val firstEntry = rowEntries.first() + "library-vertical:${firstEntry.item.type}:${firstEntry.item.id}" + }, + ) { rowEntries -> + PosterGridRow( + items = rowEntries.map { entry -> entry.item.toMetaPreview() }, + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick?.let { callback -> + { preview -> rowEntries.findEntry(preview)?.item?.let(callback) } + }, + onPosterLongClick = onPosterLongClick?.let { callback -> + { preview -> + rowEntries.findEntry(preview)?.let { entry -> callback(entry.item, entry.section) } + } + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalSkeletonItems(columns: Int) { + items( + count = 2, + key = { index -> "library-vertical-skeleton:$index" }, + ) { + PosterGridSkeletonRow( + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } +} + +internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier = + Modifier.animateItem( + fadeInSpec = tween(durationMillis = 160), + placementSpec = tween(durationMillis = 180), + fadeOutSpec = tween(durationMillis = 90), + ) + +@Composable +private fun librarySortOptionLabel(option: LibrarySortOption): String = + when (option) { + LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order) + LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc) + LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc) + LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc) + LibrarySortOption.TITLE_DESC -> stringResource(Res.string.library_sort_title_desc) + } + +private fun List.findEntry(preview: MetaPreview): LibraryVerticalEntry? = + firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 9e29c5776..b5b2682d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.library +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -12,10 +13,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -28,13 +31,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.ViewAgenda import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -77,6 +85,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState @@ -92,7 +101,7 @@ fun LibraryScreen( scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, ) { @@ -109,6 +118,11 @@ fun LibraryScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() + val displaySettings by remember { + LibraryDisplaySettingsRepository.ensureLoaded() + LibraryDisplaySettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeCatalogSettingsUiState by remember { HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState @@ -121,13 +135,42 @@ fun LibraryScreen( } var selectedProviderId by rememberSaveable { mutableStateOf(null) } var selectedTypeName by rememberSaveable { mutableStateOf(null) } + var cloudSearchQuery by rememberSaveable { mutableStateOf("") } val selectedType = remember(selectedTypeName) { selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } } var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) } + var selectedLibrarySectionKey by rememberSaveable { mutableStateOf(null) } + var selectedLibraryType by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val effectiveSortOption = effectiveLibrarySortOption( + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + val sortedSections = remember(uiState.sections, displaySettings.sortOption, uiState.sourceMode) { + sortLibrarySections( + sections = uiState.sections, + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + } + val verticalProjection = remember( + uiState.sections, + uiState.sourceMode, + selectedLibrarySectionKey, + selectedLibraryType, + displaySettings.sortOption, + ) { + buildLibraryVerticalProjection( + sections = uiState.sections, + sourceMode = uiState.sourceMode, + selectedSectionKey = selectedLibrarySectionKey, + selectedType = selectedLibraryType, + sortOption = displaySettings.sortOption, + ) + } val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) coroutineScope.launch { @@ -174,151 +217,231 @@ fun LibraryScreen( val disintegration = remember { LibraryDisintegrationHolder() } val librarySectionsDisplay = if ( - sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty() + sourceMode != LibraryViewMode.Cloud && + displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL && + uiState.isLoaded && + sortedSections.isNotEmpty() ) { - disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT) + disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT) } else { disintegration.reset() emptyList() } - NuvioScreen( - modifier = modifier, - horizontalPadding = 0.dp, - listState = listState, - ) { - stickyHeader { - Box(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.background) - .nuvioConsumePointerEvents(), - ) - androidx.compose.foundation.layout.Column( - modifier = Modifier.fillMaxWidth(), - ) { - NuvioScreenHeader( - title = if (sourceMode == LibraryViewMode.Cloud) { - stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) - } else { - stringResource(Res.string.library_title) - }, - modifier = Modifier.padding(horizontal = 16.dp), + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val gridColumns = remember(maxWidth) { posterGridColumnCountForWidth(maxWidth) } + + NuvioScreen( + modifier = Modifier.fillMaxSize(), + horizontalPadding = 0.dp, + listState = listState, + ) { + stickyHeader { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - LibrarySourceSwitch( - selectedMode = sourceMode, - onModeSelected = { mode -> - sourceModeName = mode.name - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, + modifier = Modifier.padding(horizontal = 16.dp), + actions = { + if (sourceMode == LibraryViewMode.Saved) { + val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) { + LibraryLayoutMode.VERTICAL + } else { + LibraryLayoutMode.HORIZONTAL + } + IconButton( + onClick = { + LibraryDisplaySettingsRepository.setLayoutMode(targetLayout) + }, + ) { + Crossfade( + targetState = targetLayout, + animationSpec = tween(durationMillis = 140), + label = "libraryLayoutAction", + ) { animatedTargetLayout -> + Icon( + imageVector = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + Icons.Rounded.GridView + } else { + Icons.Rounded.ViewAgenda + }, + contentDescription = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + stringResource(Res.string.library_layout_show_vertical) + } else { + stringResource(Res.string.library_layout_show_horizontal) + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } - } - if (sourceMode == LibraryViewMode.Cloud) { - cloudLibraryContent( - uiState = cloudUiState, - selectedProviderId = selectedProviderId, - selectedType = selectedType, - selectedCloudItemKey = selectedCloudItemKey, - onProviderSelected = { - selectedProviderId = it - selectedTypeName = null - selectedCloudItemKey = null - }, - onTypeSelected = { - selectedTypeName = it?.name - selectedCloudItemKey = null - }, - onItemSelected = { item -> - val playableFiles = item.playableFiles - when { - playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) - playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey - } - }, - onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, - onBackToItems = { selectedCloudItemKey = null }, - onRefresh = { CloudLibraryRepository.refresh() }, - onConnectCloudClick = onConnectCloudClick, - ) - } else { - when { - !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { - items(3) { - HomeSkeletonRow( - modifier = Modifier.padding(horizontal = 16.dp), - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - ) - } - } - - !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) + if (sourceMode == LibraryViewMode.Cloud) { + cloudLibraryContent( + uiState = cloudUiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + selectedCloudItemKey = selectedCloudItemKey, + searchQuery = cloudSearchQuery, + onSearchQueryChange = { + cloudSearchQuery = it + selectedCloudItemKey = null + }, + onProviderSelected = { + selectedProviderId = it + selectedTypeName = null + selectedCloudItemKey = null + }, + onTypeSelected = { + selectedTypeName = it?.name + selectedCloudItemKey = null + }, + onItemSelected = { item -> + val playableFiles = item.playableFiles + when { + playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) + playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey + } + }, + onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, + onBackToItems = { selectedCloudItemKey = null }, + onRefresh = { CloudLibraryRepository.refresh() }, + onConnectCloudClick = onConnectCloudClick, + ) + } else { + when { + !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { + if (displaySettings.layoutMode == LibraryLayoutMode.VERTICAL) { + libraryVerticalSkeletonItems(gridColumns) } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) + items(3) { + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) + } + } + } + + !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, + message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, + ) + } + } + } + + uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike && isTraktSource) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) + } else { + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) + }, + ) + } + } + } + + else -> { + item( + key = "library-saved-controls:${uiState.sourceMode}:" + + "${displaySettings.layoutMode}:$effectiveSortOption", + ) { + LibrarySavedControls( + layoutMode = displaySettings.layoutMode, + sourceMode = uiState.sourceMode, + sortOption = effectiveSortOption, + verticalProjection = verticalProjection, + onSectionSelected = { sectionKey -> + selectedLibrarySectionKey = sectionKey + selectedLibraryType = null }, - message = uiState.errorMessage.orEmpty(), - actionLabel = stringResource(Res.string.action_retry), - onActionClick = retryLibraryLoad, + onTypeSelected = { type -> selectedLibraryType = type }, + onSortSelected = LibraryDisplaySettingsRepository::setSortOption, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } + when (displaySettings.layoutMode) { + LibraryLayoutMode.HORIZONTAL -> librarySections( + displaySections = librarySectionsDisplay, + watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + sortOption = effectiveSortOption, + onPosterClick = onPosterClick, + onSectionViewAllClick = onSectionViewAllClick, + onPosterLongClick = onPosterLongClick, + onDisintegrated = disintegration::onExited, + ) + LibraryLayoutMode.VERTICAL -> libraryVerticalContent( + projection = verticalProjection, + columns = gridColumns, + watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, ) } } } - - uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) - }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) - }, - ) - } - } - } - - else -> { - librarySections( - displaySections = librarySectionsDisplay, - watchedKeys = watchedUiState.watchedKeys, - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - onPosterClick = onPosterClick, - onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = onPosterLongClick, - onDisintegrated = disintegration::onExited, - ) - } } } } @@ -329,6 +452,8 @@ private fun LazyListScope.cloudLibraryContent( selectedProviderId: String?, selectedType: CloudLibraryItemType?, selectedCloudItemKey: String?, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, onProviderSelected: (String?) -> Unit, onTypeSelected: (CloudLibraryItemType?) -> Unit, onItemSelected: (CloudLibraryItem) -> Unit, @@ -374,8 +499,19 @@ private fun LazyListScope.cloudLibraryContent( .distinct() .sortedBy { type -> type.ordinal } val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes } - val filteredItems = providerItems + val typeFilteredItems = providerItems .filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType } + // Local filter over the already-loaded library. Matches the item name or any of its + // file names, since the useful identifier is often in the filename, not the title. + val trimmedQuery = searchQuery.trim() + val filteredItems = if (trimmedQuery.isEmpty()) { + typeFilteredItems + } else { + typeFilteredItems.filter { item -> + item.name.contains(trimmedQuery, ignoreCase = true) || + item.files.any { file -> file.name.contains(trimmedQuery, ignoreCase = true) } + } + } val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey } if (selectedItem != null) { @@ -400,6 +536,14 @@ private fun LazyListScope.cloudLibraryContent( ) } + item(key = "cloud-library-search") { + CloudLibrarySearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + uiState.providers .filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId } .filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() } @@ -443,6 +587,38 @@ private fun LazyListScope.cloudLibraryContent( } } +@Composable +private fun CloudLibrarySearchField( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + placeholder = { Text(stringResource(Res.string.cloud_library_search_label)) }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + ) + } + } + }, + ) +} + private fun LazyListScope.cloudLibrarySkeletonItems() { item(key = "cloud-library-skeleton-toolbar") { CloudLibrarySkeletonToolbar( @@ -1000,24 +1176,26 @@ private fun LazyListScope.librarySections( displaySections: List, watchedKeys: Set, showHeaderAccent: Boolean, + sortOption: LibrarySortOption, onPosterClick: ((LibraryItem) -> Unit)?, - onSectionViewAllClick: ((LibrarySection) -> Unit)?, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, onDisintegrated: (String) -> Unit, ) { items( items = displaySections, - key = { section -> section.type }, + key = { section -> "library-horizontal:${section.type}" }, ) { section -> NuvioShelfSection( title = section.displayTitle, entries = section.previewEntries, + modifier = libraryContentTransitionModifier(), headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), showHeaderAccent = showHeaderAccent, onViewAllClick = section.source ?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT } - ?.let { source -> onSectionViewAllClick?.let { { it(source) } } }, + ?.let { source -> onSectionViewAllClick?.let { { it(source, sortOption) } } }, viewAllPillSize = NuvioViewAllPillSize.Compact, key = { entry -> entry.globalKey }, animatePlacement = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt index b1ca1c1e1..1316e3720 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListMetadataService.kt @@ -23,6 +23,7 @@ object MdbListMetadataService { const val PROVIDER_TRAKT = "trakt" const val PROVIDER_LETTERBOXD = "letterboxd" const val PROVIDER_AUDIENCE = "audience" + const val PROVIDER_MAL = "mal" val PROVIDER_PRIORITY_ORDER = listOf( PROVIDER_IMDB, @@ -32,6 +33,7 @@ object MdbListMetadataService { PROVIDER_TRAKT, PROVIDER_LETTERBOXD, PROVIDER_AUDIENCE, + PROVIDER_MAL, ) private val log = Logger.withTag("MdbListMetadata") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt index e9002303e..31435d398 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettings.kt @@ -10,6 +10,7 @@ data class MdbListSettings( val useTrakt: Boolean = true, val useLetterboxd: Boolean = true, val useAudience: Boolean = true, + val useMal: Boolean = true, ) { val hasApiKey: Boolean get() = apiKey.isNotBlank() @@ -23,6 +24,7 @@ data class MdbListSettings( MdbListMetadataService.PROVIDER_TRAKT -> useTrakt MdbListMetadataService.PROVIDER_LETTERBOXD -> useLetterboxd MdbListMetadataService.PROVIDER_AUDIENCE -> useAudience + MdbListMetadataService.PROVIDER_MAL -> useMal else -> false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt index 6f48eb800..4a7f44e27 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsRepository.kt @@ -19,6 +19,7 @@ object MdbListSettingsRepository { private var useTrakt = true private var useLetterboxd = true private var useAudience = true + private var useMal = true fun ensureLoaded() { if (hasLoaded) return @@ -88,6 +89,10 @@ object MdbListSettingsRepository { useAudience = value MdbListSettingsStorage.saveUseAudience(value) } else return + MdbListMetadataService.PROVIDER_MAL -> if (useMal != value) { + useMal = value + MdbListSettingsStorage.saveUseMal(value) + } else return else -> return } publish() @@ -105,6 +110,7 @@ object MdbListSettingsRepository { useTrakt = MdbListSettingsStorage.loadUseTrakt() ?: true useLetterboxd = MdbListSettingsStorage.loadUseLetterboxd() ?: true useAudience = MdbListSettingsStorage.loadUseAudience() ?: true + useMal = MdbListSettingsStorage.loadUseMal() ?: true publish() } @@ -119,6 +125,7 @@ object MdbListSettingsRepository { useTrakt = useTrakt, useLetterboxd = useLetterboxd, useAudience = useAudience, + useMal = useMal, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt index c1a6f51e1..ff9cd57fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.kt @@ -21,6 +21,8 @@ internal expect object MdbListSettingsStorage { fun saveUseLetterboxd(enabled: Boolean) fun loadUseAudience(): Boolean? fun saveUseAudience(enabled: Boolean) + fun loadUseMal(): Boolean? + fun saveUseMal(enabled: Boolean) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt index 092b855f7..198ba645e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/p2p/P2pStreaming.kt @@ -8,7 +8,35 @@ import kotlinx.coroutines.flow.asStateFlow data class P2pSettingsUiState( val p2pEnabled: Boolean = false, val enableUpload: Boolean = true, - val hideTorrentStats: Boolean = true, + val hideTorrentStats: Boolean = false, + val torrentProfile: P2pTorrentProfile = P2pTorrentProfile.BALANCED, + val cacheSize: P2pCacheSize = P2pCacheSize.GB_2, +) + +enum class P2pTorrentProfile { + SOFT, + BALANCED, + FAST, +} + +enum class P2pCacheSize(val bytes: Long) { + NONE(0L), + GB_2(2L * 1024L * 1024L * 1024L), + GB_5(5L * 1024L * 1024L * 1024L), + GB_10(10L * 1024L * 1024L * 1024L), +} + +data class P2pCacheUiState( + val usedBytes: Long = 0L, + val protectedBytes: Long = 0L, + val isClearing: Boolean = false, + val hasMeasurement: Boolean = false, +) + +data class P2pCacheClearResult( + val reclaimedBytes: Long, + val remainingBytes: Long, + val protectedBytes: Long, ) object P2pSettingsRepository { @@ -21,7 +49,9 @@ object P2pSettingsRepository { private var hasLoaded = false private var p2pEnabled = false private var enableUpload = true - private var hideTorrentStats = true + private var hideTorrentStats = false + private var torrentProfile = P2pTorrentProfile.BALANCED + private var cacheSize = P2pCacheSize.GB_2 fun ensureLoaded() { if (hasLoaded) return @@ -36,7 +66,9 @@ object P2pSettingsRepository { hasLoaded = false p2pEnabled = false enableUpload = true - hideTorrentStats = true + hideTorrentStats = false + torrentProfile = P2pTorrentProfile.BALANCED + cacheSize = P2pCacheSize.GB_2 publish() } @@ -64,11 +96,33 @@ object P2pSettingsRepository { publish() } + fun setTorrentProfile(profile: P2pTorrentProfile) { + ensureLoaded() + if (torrentProfile == profile) return + torrentProfile = profile + P2pSettingsStorage.saveTorrentProfile(profile.name) + publish() + } + + fun setCacheSize(size: P2pCacheSize) { + ensureLoaded() + if (cacheSize == size) return + cacheSize = size + P2pSettingsStorage.saveCacheSize(size.name) + publish() + } + private fun loadFromDisk() { hasLoaded = true p2pEnabled = P2pSettingsStorage.loadP2pEnabled() ?: false enableUpload = P2pSettingsStorage.loadEnableUpload() ?: true - hideTorrentStats = P2pSettingsStorage.loadHideTorrentStats() ?: true + hideTorrentStats = P2pSettingsStorage.loadHideTorrentStats() ?: false + torrentProfile = P2pSettingsStorage.loadTorrentProfile() + ?.let { stored -> P2pTorrentProfile.entries.firstOrNull { it.name == stored } } + ?: P2pTorrentProfile.BALANCED + cacheSize = P2pSettingsStorage.loadCacheSize() + ?.let { stored -> P2pCacheSize.entries.firstOrNull { it.name == stored } } + ?: P2pCacheSize.GB_2 publish() } @@ -77,6 +131,8 @@ object P2pSettingsRepository { p2pEnabled = p2pEnabled, enableUpload = enableUpload, hideTorrentStats = hideTorrentStats, + torrentProfile = torrentProfile, + cacheSize = cacheSize, ) } } @@ -88,6 +144,10 @@ internal expect object P2pSettingsStorage { fun saveEnableUpload(enabled: Boolean) fun loadHideTorrentStats(): Boolean? fun saveHideTorrentStats(enabled: Boolean) + fun loadTorrentProfile(): String? + fun saveTorrentProfile(profile: String) + fun loadCacheSize(): String? + fun saveCacheSize(size: String) } data class P2pStreamRequest( @@ -97,9 +157,56 @@ data class P2pStreamRequest( val trackers: List = emptyList(), ) +internal fun canonicalP2pInfoHash(infoHash: String): String { + val canonical = infoHash.trim().lowercase() + require((canonical.length == 40 || canonical.length == 64) && + canonical.all { it in '0'..'9' || it in 'a'..'f' }) { + "Torrent info hash must be 40 or 64 hexadecimal characters" + } + return canonical +} + +internal fun buildP2pMagnetUri(infoHash: String, trackers: List): String { + val canonicalHash = canonicalP2pInfoHash(infoHash) + val topic = if (canonicalHash.length == 40) { + "urn:btih:$canonicalHash" + } else { + "urn:btmh:1220$canonicalHash" + } + val trackerParameters = trackers.filter(String::isNotBlank).distinct().joinToString("") { tracker -> + "&tr=${tracker.encodeP2pQueryValue()}" + } + return "magnet:?xt=$topic$trackerParameters" +} + +private fun String.encodeP2pQueryValue(): String = buildString { + for (byte in this@encodeP2pQueryValue.encodeToByteArray()) { + val value = byte.toInt() and 0xff + if ((value in 'a'.code..'z'.code) || + (value in 'A'.code..'Z'.code) || + (value in '0'.code..'9'.code) || + value == '-'.code || value == '.'.code || value == '_'.code || value == '~'.code) { + append(value.toChar()) + } else { + append('%') + append(HEX_DIGITS[value ushr 4]) + append(HEX_DIGITS[value and 0x0f]) + } + } +} + +private const val HEX_DIGITS = "0123456789ABCDEF" + sealed class P2pStreamingState { data object Idle : P2pStreamingState() - data object Connecting : P2pStreamingState() + + data class Connecting( + val phase: String = "starting_engine", + val downloadSpeed: Long = 0L, + val uploadSpeed: Long = 0L, + val peers: Int = 0, + val seeds: Int = 0, + ) : P2pStreamingState() data class Streaming( val localUrl: String, @@ -109,7 +216,9 @@ sealed class P2pStreamingState { val seeds: Int, val bufferProgress: Float, val totalProgress: Float, - val preloadedBytes: Long = 0L, + val downloadedBytes: Long = 0L, + val verifiedBytes: Long = 0L, + val deliveredBytes: Long = 0L, ) : P2pStreamingState() data class Error(val message: String) : P2pStreamingState() @@ -119,7 +228,9 @@ class P2pStreamingException(message: String) : Exception(message) expect object P2pStreamingEngine { val state: StateFlow + val cacheState: StateFlow suspend fun startStream(request: P2pStreamRequest): String + suspend fun clearCache(): P2pCacheClearResult fun stopStream() fun shutdown() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 846c10200..f8bb49054 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -65,8 +65,11 @@ expect fun PlatformPlayerSurface( useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, + initialPositionMs: Long? = null, + initialPositionRequestKey: String? = null, resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, useNativeController: Boolean = false, + onInitialPositionHandled: (key: String, handled: Boolean) -> Unit = { _, _ -> }, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index d3ae9b4b4..c9ba11159 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -1,6 +1,5 @@ package com.nuvio.app.features.player -import androidx.compose.animation.Crossfade import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -247,30 +246,23 @@ internal fun OpeningOverlay( val showHorizontalProgress = progressActive && logo == null if (!message.isNullOrBlank() || showHorizontalProgress) { Spacer(modifier = Modifier.height(16.dp)) - Crossfade( - targetState = message?.takeIf { it.isNotBlank() }, - animationSpec = tween(durationMillis = 260), - label = "openingOverlayMessageCrossfade", + Box( modifier = Modifier .fillMaxWidth() .height(40.dp), - ) { loadingMessage -> - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (loadingMessage != null) { - Text( - text = loadingMessage, - color = Color.White.copy(alpha = 0.72f), - textAlign = TextAlign.Center, - maxLines = 2, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - ) - } + contentAlignment = Alignment.Center, + ) { + message?.takeIf { it.isNotBlank() }?.let { loadingMessage -> + Text( + text = loadingMessage, + color = Color.White.copy(alpha = 0.72f), + textAlign = TextAlign.Center, + maxLines = 2, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) } } if (showHorizontalProgress) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt index 81e1cf1bb..02df155ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -99,6 +99,8 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { return@LaunchedEffect } if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) { + p2pResolvedSourceUrl = null + P2pStreamingEngine.stopStream() return@LaunchedEffect } @@ -142,6 +144,11 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) { val state = p2pStreamingState if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) { + p2pResolvedSourceUrl = null + playerController = null + playerControllerSourceUrl = null + playbackSnapshot = PlayerPlaybackSnapshot() + initialLoadCompleted = true errorMessage = getString(Res.string.player_error_torrent, state.message) controlsVisible = !playerControlsLocked } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index b6f4e42ed..43b4977a8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -64,8 +64,8 @@ internal class PlayerScreenRuntime( lateinit var hapticFeedback: HapticFeedback var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState() - var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState() - var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle + var p2pSettingsUiState by mutableStateOf(P2pSettingsUiState()) + var p2pStreamingState by mutableStateOf(P2pStreamingState.Idle) var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() var watchedUiState: WatchedUiState = WatchedUiState() var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 227a9d59b..d83c22fd1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -25,6 +25,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback val isP2pPlaybackActive = activeTorrentInfoHash != null + val p2pConnecting = p2pStreamingState as? P2pStreamingState.Connecting val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming val p2pPeerInfo = p2pStats?.let { stats -> org.jetbrains.compose.resources.stringResource( @@ -34,20 +35,35 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { ) } val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) } + val p2pLoadingBytes = p2pStats?.let { maxOf(it.downloadedBytes, it.deliveredBytes) } ?: 0L + val connectingPeerInfo = p2pConnecting?.let { state -> + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info, + state.seeds, + state.peers, + ) + } val p2pInitialLoadingMessage = when { !isP2pPlaybackActive || initialLoadCompleted -> null - p2pStreamingState is P2pStreamingState.Connecting -> { - org.jetbrains.compose.resources.stringResource( - nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers, - ) + p2pConnecting != null -> { + if (p2pSettingsUiState.hideTorrentStats) { + p2pConnectingPhaseLabel(p2pConnecting.phase) + } else { + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_status, + p2pConnectingPhaseLabel(p2pConnecting.phase), + connectingPeerInfo.orEmpty(), + formatP2pSpeed(p2pConnecting.downloadSpeed), + ) + } } p2pStats != null -> { if (p2pSettingsUiState.hideTorrentStats) { null } else { org.jetbrains.compose.resources.stringResource( - nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status, - formatP2pMegabytes(p2pStats.preloadedBytes), + nuvio.composeapp.generated.resources.Res.string.player_torrent_loading_status, + formatP2pMegabytes(p2pLoadingBytes), p2pPeerInfo.orEmpty(), p2pDownloadSpeed.orEmpty(), ) @@ -57,9 +73,15 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine, ) } + val bufferedAheadMs = (playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) + .coerceAtLeast(0L) val p2pInitialLoadingProgress = when { !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null - else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f) + else -> p2pInitialLoadingProgress( + bufferedAheadMs = bufferedAheadMs, + downloadedBytes = p2pStats.downloadedBytes, + deliveredBytes = p2pStats.deliveredBytes, + ) } val showP2pRebufferStats = isP2pPlaybackActive && initialLoadCompleted && @@ -116,6 +138,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { ), ) { val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl + val initialPositionRequestKey = currentInitialPositionRequestKey() if (playerSurfaceSourceUrl != null) { PlatformPlayerSurface( sourceUrl = playerSurfaceSourceUrl, @@ -126,7 +149,14 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { streamType = activeStreamType, modifier = Modifier.fillMaxSize(), playWhenReady = shouldPlay, + initialPositionMs = activeInitialPositionMs.takeIf { it > 0L }, + initialPositionRequestKey = initialPositionRequestKey, resizeMode = resizeMode, + onInitialPositionHandled = { key, handled -> + if (key == currentInitialPositionRequestKey()) { + initialSeekApplied = handled + } + }, onControllerReady = { controller -> playerController = controller playerControllerSourceUrl = activeSourceUrl @@ -187,6 +217,24 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { } } +@Composable +private fun p2pConnectingPhaseLabel(phase: String): String = when (phase) { + "add_magnet" -> org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_fetching_metadata, + ) + "prepare_stream", "attach_route" -> org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_preparing_stream, + ) + else -> org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine, + ) +} + +private fun PlayerScreenRuntime.currentInitialPositionRequestKey(): String? { + val positionMs = activeInitialPositionMs.takeIf { it > 0L } ?: return null + return "$activePlaybackIdentity:${activeVideoId.orEmpty()}:$positionMs" +} + @Composable private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { val isInPip = rememberIsInPictureInPicture() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt index 83c7944ad..20970103d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt @@ -15,9 +15,39 @@ internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f internal const val PlayerVerticalGestureMinHeightFraction = 0.06f internal const val PlayerVerticalGestureDominanceRatio = 1.2f internal const val PlayerSeekProgressSyncDebounceMs = 700L -internal const val P2pInitialPreloadTargetBytes = 5_242_880L +internal const val P2pInitialByteProgressMidpoint = 5_242_880L +internal const val P2pInitialBufferTargetMs = 10_000L +internal const val P2pInitialNetworkStageWeight = 0.45f +internal const val P2pInitialDeliveryStageWeight = 0.30f +internal const val P2pInitialPlayerStageStart = 0.75f +internal const val P2pInitialLoadingMaximum = 0.95f internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L +internal fun p2pInitialLoadingProgress( + bufferedAheadMs: Long, + downloadedBytes: Long, + deliveredBytes: Long, +): Float { + val networkProgress = saturatingProgress(downloadedBytes, P2pInitialByteProgressMidpoint) * + P2pInitialNetworkStageWeight + val deliveryProgress = saturatingProgress(deliveredBytes, P2pInitialByteProgressMidpoint) * + P2pInitialDeliveryStageWeight + val engineProgress = networkProgress + deliveryProgress + val playerProgress = if (bufferedAheadMs > 0L) { + P2pInitialPlayerStageStart + + (bufferedAheadMs.toFloat() / P2pInitialBufferTargetMs.toFloat()).coerceIn(0f, 1f) * + (P2pInitialLoadingMaximum - P2pInitialPlayerStageStart) + } else { + 0f + } + return maxOf(engineProgress, playerProgress).coerceIn(0f, P2pInitialLoadingMaximum) +} + +private fun saturatingProgress(value: Long, midpoint: Long): Float { + val safeValue = value.coerceAtLeast(0L).toDouble() + return (safeValue / (safeValue + midpoint.toDouble())).toFloat() +} + internal val PlayerSideGestureSystemEdgeExclusion = 72.dp internal val PlayerSliderOverlayGap = 12.dp internal val PlayerTimeRowHeight = 36.dp diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index fd76b3fef..c7e1faa0e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -16,6 +16,7 @@ import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.p2p.P2pSettingsRepository @@ -154,8 +155,9 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) TraktSettingsRepository.onProfileChanged() - TraktAuthRepository.onProfileChanged() + TraktAuthRepository.onProfileChanged(profileIndex) LibraryRepository.onProfileChanged(profileIndex) + LibraryDisplaySettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index ed58b7f28..ed4331294 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -1,48 +1,32 @@ package com.nuvio.app.features.search -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition -import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay -import com.nuvio.app.core.ui.rememberPosterCardStyleUiState -import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow import com.nuvio.app.features.home.components.HomeEmptyStateCard -import com.nuvio.app.features.watching.application.WatchingState import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -92,7 +76,7 @@ internal fun LazyListScope.discoverContent( when { state.isLoading && state.items.isEmpty() -> { items(2) { - DiscoverSkeletonRow( + PosterGridSkeletonRow( columns = columns, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -113,7 +97,7 @@ internal fun LazyListScope.discoverContent( else -> { items(state.items.chunked(columns)) { rowItems -> - DiscoverGridRow( + PosterGridRow( items = rowItems, columns = columns, modifier = Modifier.padding(horizontal = 16.dp), @@ -193,129 +177,6 @@ private fun DiscoverFilterRow( } } -@Composable -private fun DiscoverGridRow( - items: List, - columns: Int, - modifier: Modifier = Modifier, - watchedKeys: Set = emptySet(), - fullyWatchedSeriesKeys: Set = emptySet(), - onPosterClick: ((MetaPreview) -> Unit)? = null, - onPosterLongClick: ((MetaPreview) -> Unit)? = null, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items.forEach { item -> - DiscoverPosterTile( - item = item, - cornerRadiusDp = posterCardStyle.cornerRadiusDp, - hideLabels = posterCardStyle.hideLabelsEnabled, - modifier = Modifier.weight(1f), - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, - item = item, - fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item) } }, - ) - } - repeat(columns - items.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun DiscoverPosterTile( - item: MetaPreview, - cornerRadiusDp: Int, - hideLabels: Boolean, - modifier: Modifier = Modifier, - isWatched: Boolean = false, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(item.posterShape.discoverAspectRatio()) - .clip(RoundedCornerShape(cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface) - .posterCardClickable( - onClick = onClick, - onLongClick = onLongClick, - zoomImageUrl = item.poster, - zoomCornerRadius = cornerRadiusDp.dp, - ), - ) { - if (item.poster != null) { - AsyncImage( - model = item.poster, - contentDescription = item.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - NuvioPosterWatchedOverlay(isWatched = isWatched) - } - if (!hideLabels) { - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun DiscoverSkeletonRow( - columns: Int, - modifier: Modifier = Modifier, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - repeat(columns) { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(0.68f) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface), - ) - } - } -} - @Composable private fun CatalogLoadingFooter(modifier: Modifier = Modifier) { Box( @@ -390,10 +251,3 @@ private fun String.displayTypeLabel(): String = "tv" -> stringResource(Res.string.media_tv) else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - -private fun PosterShape.discoverAspectRatio(): Float = - when (this) { - PosterShape.Poster -> 0.68f - PosterShape.Square -> 1f - PosterShape.Landscape -> 1.2f - } 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 4520befe5..104e63e3d 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 @@ -35,7 +35,6 @@ 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.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -56,6 +55,7 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.watched.WatchedRepository import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -226,7 +226,7 @@ fun SearchScreen( modifier = modifier.fillMaxSize(), ) { val discoverColumns = remember(maxWidth) { - discoverColumnCountForWidth(maxWidth) + posterGridColumnCountForWidth(maxWidth) } val homeSectionPadding = remember(maxWidth) { homeSectionHorizontalPaddingForWidth(maxWidth.value) @@ -382,15 +382,6 @@ fun SearchScreen( } } -private fun discoverColumnCountForWidth(screenWidth: Dp): Int = - when { - screenWidth >= 1400.dp -> 7 - screenWidth >= 1200.dp -> 6 - screenWidth >= 1000.dp -> 5 - screenWidth >= 840.dp -> 4 - else -> 3 - } - @Composable private fun SearchEmptyStateCard( reason: SearchEmptyStateReason?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index e12425041..f33af7536 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -64,8 +64,10 @@ import nuvio.composeapp.generated.resources.settings_appearance_continue_watchin import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description +import nuvio.composeapp.generated.resources.settings_appearance_section_detail_page import nuvio.composeapp.generated.resources.settings_appearance_section_display import nuvio.composeapp.generated.resources.settings_appearance_section_home +import nuvio.composeapp.generated.resources.settings_appearance_section_streams import nuvio.composeapp.generated.resources.settings_appearance_section_theme import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description @@ -252,7 +254,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { SettingsSection( - title = stringResource(Res.string.compose_settings_page_streams), + title = stringResource(Res.string.settings_appearance_section_streams), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -267,7 +269,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { SettingsSection( - title = stringResource(Res.string.compose_settings_page_meta_screen), + title = stringResource(Res.string.settings_appearance_section_detail_page), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 75275178d..5608e5904 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title import nuvio.composeapp.generated.resources.settings_continue_watching_style_card import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description @@ -176,6 +178,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( when (sortMode) { ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + ContinueWatchingSortMode.SPLIT_UPCOMING -> Res.string.settings_continue_watching_sort_mode_split_upcoming } ) SettingsNavigationRow( @@ -325,6 +328,11 @@ private fun ContinueWatchingSortModeDialog( Res.string.settings_continue_watching_sort_mode_streaming, Res.string.settings_continue_watching_sort_mode_streaming_desc, ), + Triple( + ContinueWatchingSortMode.SPLIT_UPCOMING, + Res.string.settings_continue_watching_sort_mode_split_upcoming, + Res.string.settings_continue_watching_sort_mode_split_upcoming_desc, + ), ) BasicAlertDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt index 3a7a2c40a..49842bf34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt @@ -34,6 +34,7 @@ import nuvio.composeapp.generated.resources.settings_mdb_section_title import nuvio.composeapp.generated.resources.source_audience_score import nuvio.composeapp.generated.resources.source_imdb import nuvio.composeapp.generated.resources.source_letterboxd +import nuvio.composeapp.generated.resources.source_mal import nuvio.composeapp.generated.resources.source_metacritic import nuvio.composeapp.generated.resources.source_rotten_tomatoes import nuvio.composeapp.generated.resources.source_tmdb @@ -117,6 +118,7 @@ private fun ProviderRows( MdbListMetadataService.PROVIDER_TRAKT to Res.string.source_trakt, MdbListMetadataService.PROVIDER_LETTERBOXD to Res.string.source_letterboxd, MdbListMetadataService.PROVIDER_AUDIENCE to Res.string.source_audience_score, + MdbListMetadataService.PROVIDER_MAL to Res.string.source_mal, ) providers.forEachIndexed { index, (providerId, providerLabelRes) -> 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 46b733c52..3dcf5a169 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 @@ -75,7 +75,12 @@ import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode import com.nuvio.app.features.player.toStorageHexString import com.nuvio.app.features.p2p.P2pConsentDialog +import com.nuvio.app.features.p2p.P2pCacheClearResult +import com.nuvio.app.features.p2p.P2pCacheSize import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.p2p.P2pTorrentProfile import com.nuvio.app.features.plugins.PluginsUiState import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.streams.StreamAutoPlayMode @@ -144,6 +149,31 @@ private fun formatStep(value: Float): String { } } +@Composable +private fun p2pProfileLabel(profile: P2pTorrentProfile): String = when (profile) { + P2pTorrentProfile.SOFT -> stringResource(Res.string.settings_p2p_profile_soft) + P2pTorrentProfile.BALANCED -> stringResource(Res.string.settings_p2p_profile_balanced) + P2pTorrentProfile.FAST -> stringResource(Res.string.settings_p2p_profile_fast) +} + +@Composable +private fun p2pCacheSizeLabel(size: P2pCacheSize): String = when (size) { + P2pCacheSize.NONE -> stringResource(Res.string.settings_p2p_cache_none) + P2pCacheSize.GB_2 -> stringResource(Res.string.settings_p2p_cache_2_gb) + P2pCacheSize.GB_5 -> stringResource(Res.string.settings_p2p_cache_5_gb) + P2pCacheSize.GB_10 -> stringResource(Res.string.settings_p2p_cache_10_gb) +} + +private fun formatP2pCacheBytes(bytes: Long): String { + val gibibyte = 1024.0 * 1024.0 * 1024.0 + val mebibyte = 1024.0 * 1024.0 + return if (bytes >= gibibyte) { + "${kotlin.math.round(bytes / gibibyte * 10.0) / 10.0} GB" + } else { + "${kotlin.math.round(bytes / mebibyte * 10.0) / 10.0} MB" + } +} + @Composable private fun addonSubtitleStartupModeLabel(mode: AddonSubtitleStartupMode): String = when (mode) { @@ -298,12 +328,19 @@ private fun PlaybackSettingsSection( var showAutoPlayPluginSelectionDialog by remember { mutableStateOf(false) } var showAutoPlayRegexDialog by remember { mutableStateOf(false) } var showP2pConsentDialog by remember { mutableStateOf(false) } + var showP2pProfileDialog by remember { mutableStateOf(false) } + var showP2pCacheSizeDialog by remember { mutableStateOf(false) } + var p2pCacheClearResult by remember { mutableStateOf(null) } + var p2pCacheClearFailed by remember { mutableStateOf(false) } val pluginsEnabled = AppFeaturePolicy.pluginsEnabled val autoPlayPlayerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle() val p2pSettings by remember { P2pSettingsRepository.ensureLoaded() P2pSettingsRepository.uiState }.collectAsStateWithLifecycle() + val p2pCacheState by P2pStreamingEngine.cacheState.collectAsStateWithLifecycle() + val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() val availableExternalPlayers = ExternalPlayerPlatform.availablePlayers() val selectedExternalPlayer = availableExternalPlayers.firstOrNull { it.id == autoPlayPlayerSettings.externalPlayerId @@ -616,7 +653,7 @@ private fun PlaybackSettingsSection( if (P2pSettingsRepository.isVisible) { SettingsSection( - title = stringResource(Res.string.settings_p2p_title), + title = stringResource(Res.string.settings_playback_section_p2p), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -641,6 +678,56 @@ private fun PlaybackSettingsSection( isTablet = isTablet, onCheckedChange = P2pSettingsRepository::setHideTorrentStats, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_p2p_profile_title), + description = p2pProfileLabel(p2pSettings.torrentProfile), + isTablet = isTablet, + onClick = { showP2pProfileDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_p2p_cache_size_title), + description = p2pCacheSizeLabel(p2pSettings.cacheSize), + isTablet = isTablet, + onClick = { showP2pCacheSizeDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + val cacheClearAvailable = p2pStreamingState !is P2pStreamingState.Connecting && + p2pStreamingState !is P2pStreamingState.Streaming && + !p2pCacheState.isClearing + SettingsNavigationRow( + title = stringResource(Res.string.settings_p2p_clear_cache_title), + description = when { + p2pCacheState.isClearing -> + stringResource(Res.string.settings_p2p_clear_cache_clearing) + !cacheClearAvailable -> + stringResource(Res.string.settings_p2p_clear_cache_playback_active) + p2pCacheClearFailed -> + stringResource(Res.string.settings_p2p_clear_cache_failed) + p2pCacheClearResult != null -> stringResource( + Res.string.settings_p2p_clear_cache_done, + formatP2pCacheBytes(p2pCacheClearResult!!.reclaimedBytes), + ) + !p2pCacheState.hasMeasurement -> + stringResource(Res.string.settings_p2p_clear_cache_usage_pending) + else -> stringResource( + Res.string.settings_p2p_clear_cache_usage, + formatP2pCacheBytes(p2pCacheState.usedBytes), + ) + }, + enabled = cacheClearAvailable, + isTablet = isTablet, + onClick = { + p2pCacheClearResult = null + p2pCacheClearFailed = false + coroutineScope.launch { + runCatching { P2pStreamingEngine.clearCache() } + .onSuccess { p2pCacheClearResult = it } + .onFailure { p2pCacheClearFailed = true } + } + }, + ) } } } @@ -1182,6 +1269,45 @@ private fun PlaybackSettingsSection( ) } + if (showP2pProfileDialog) { + IosEnumSelectionDialog( + title = stringResource(Res.string.settings_p2p_profile_title), + options = P2pTorrentProfile.entries, + selected = p2pSettings.torrentProfile, + label = { p2pProfileLabel(it) }, + description = { profile -> + when (profile) { + P2pTorrentProfile.SOFT -> + stringResource(Res.string.settings_p2p_profile_soft_description) + P2pTorrentProfile.BALANCED -> + stringResource(Res.string.settings_p2p_profile_balanced_description) + P2pTorrentProfile.FAST -> + stringResource(Res.string.settings_p2p_profile_fast_description) + } + }, + onSelect = { profile -> + P2pSettingsRepository.setTorrentProfile(profile) + showP2pProfileDialog = false + }, + onDismiss = { showP2pProfileDialog = false }, + ) + } + + if (showP2pCacheSizeDialog) { + IosEnumSelectionDialog( + title = stringResource(Res.string.settings_p2p_cache_size_title), + options = P2pCacheSize.entries, + selected = p2pSettings.cacheSize, + label = { p2pCacheSizeLabel(it) }, + onSelect = { size -> + P2pSettingsRepository.setCacheSize(size) + p2pCacheClearResult = null + showP2pCacheSizeDialog = false + }, + onDismiss = { showP2pCacheSizeDialog = false }, + ) + } + if (showSecondaryAudioDialog) { val originalHint = stringResource(Res.string.settings_playback_option_original_hint) LanguageSelectionDialog( @@ -2143,7 +2269,7 @@ private fun IosEnumSelectionDialog( title: String, options: List, selected: T, - label: (T) -> String, + label: @Composable (T) -> String, description: @Composable (T) -> String? = { null }, onSelect: (T) -> Unit, onDismiss: () -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index 360d492a7..b5d8dfb3e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -129,6 +129,12 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { } } + Spacer( + modifier = Modifier.height( + if (isTablet) NuvioTokens.Space.s18 else MaterialTheme.nuvio.spacing.listGap, + ), + ) + SettingsSection( title = stringResource(Res.string.settings_stream_display_section), isTablet = isTablet, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 20fa9eb50..a9e3308eb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -48,33 +48,37 @@ object TraktAuthRepository { val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() private var hasLoaded = false + private var currentProfileId: Int = 1 + private var profileGeneration: Long = 0L private var authState = TraktAuthState() - fun ensureLoaded() { - if (hasLoaded) return - loadFromDisk() + fun ensureLoaded(profileId: Int = ProfileRepository.activeProfileId) { + if (hasLoaded && currentProfileId == profileId) return + loadFromDisk(profileId) } - fun onProfileChanged() { - loadFromDisk() + fun onProfileChanged(profileId: Int) { + loadFromDisk(profileId) } fun clearLocalState() { hasLoaded = false + currentProfileId = 1 + profileGeneration += 1L authState = TraktAuthState() publish() } - fun snapshot(): TraktAuthUiState { - ensureLoaded() + fun snapshot(profileId: Int = ProfileRepository.activeProfileId): TraktAuthUiState { + ensureLoaded(profileId) return _uiState.value } fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() - fun onConnectRequested(): String? { - ensureLoaded() + fun onConnectRequested(profileId: Int = ProfileRepository.activeProfileId): String? { + ensureLoaded(profileId) if (!hasRequiredCredentials()) { publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) return null @@ -85,7 +89,7 @@ object TraktAuthRepository { pendingAuthorizationState = oauthState, pendingAuthorizationStartedAtMillis = TraktPlatformClock.nowEpochMs(), ) - persist() + persist(profileId) publish( statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), errorMessage = null, @@ -94,21 +98,21 @@ object TraktAuthRepository { return buildAuthorizationUrl(oauthState) } - fun pendingAuthorizationUrl(): String? { - ensureLoaded() + fun pendingAuthorizationUrl(profileId: Int = ProfileRepository.activeProfileId): String? { + ensureLoaded(profileId) val oauthState = authState.pendingAuthorizationState ?: return null return buildAuthorizationUrl(oauthState) } - fun onCancelAuthorization() { - ensureLoaded() + fun onCancelAuthorization(profileId: Int = ProfileRepository.activeProfileId) { + ensureLoaded(profileId) clearPendingAuthorization() - persist() + persist(profileId) publish(statusMessage = null, errorMessage = null) } - fun onCancelDeviceFlow() { - onCancelAuthorization() + fun onCancelDeviceFlow(profileId: Int = ProfileRepository.activeProfileId) { + onCancelAuthorization(profileId) } fun onAuthLaunchFailed(reason: String) { @@ -116,7 +120,8 @@ object TraktAuthRepository { } fun onAuthCallbackReceived(callbackUrl: String) { - ensureLoaded() + val profileId = ProfileRepository.activeProfileId + ensureLoaded(profileId) if (!callbackUrl.startsWith("${TraktConfig.REDIRECT_URI}?", ignoreCase = true) && !callbackUrl.equals(TraktConfig.REDIRECT_URI, ignoreCase = true) ) { @@ -124,15 +129,15 @@ object TraktAuthRepository { } scope.launch { - completeAuthorizationFromCallback(callbackUrl) + completeAuthorizationFromCallback(callbackUrl, profileId) } } - suspend fun authorizedHeaders(): Map? { - ensureLoaded() + suspend fun authorizedHeaders(profileId: Int = currentProfileId): Map? { + ensureLoaded(profileId) if (!authState.isAuthenticated) return null - val hasValidToken = refreshTokenIfNeeded(force = false) + val hasValidToken = refreshTokenIfNeeded(force = false, profileId = profileId) if (!hasValidToken) return null val accessToken = authState.accessToken?.trim().orEmpty() @@ -145,9 +150,9 @@ object TraktAuthRepository { ) } - suspend fun refreshUserSettings(): String? { - ensureLoaded() - val headers = authorizedHeaders() ?: return null + suspend fun refreshUserSettings(profileId: Int = currentProfileId): String? { + ensureLoaded(profileId) + val headers = authorizedHeaders(profileId) ?: return null val response = runCatching { httpGetTextWithHeaders( url = "$BASE_URL/users/settings", @@ -166,19 +171,19 @@ object TraktAuthRepository { username = parsed.user?.username, userSlug = parsed.user?.ids?.slug, ) - persist() + persist(profileId) publish() return authState.username } - fun onDisconnectRequested() { - ensureLoaded() + fun onDisconnectRequested(profileId: Int = currentProfileId) { + ensureLoaded(profileId) scope.launch { - disconnect() + disconnect(profileId) } } - private suspend fun completeAuthorizationFromCallback(callbackUrl: String) { + private suspend fun completeAuthorizationFromCallback(callbackUrl: String, profileId: Int = currentProfileId) { publish(isLoading = true, errorMessage = null) val parsedUrl = runCatching { Url(callbackUrl) } @@ -189,7 +194,7 @@ object TraktAuthRepository { if (parsedUrl == null) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_callback), @@ -202,7 +207,7 @@ object TraktAuthRepository { val errorDescription = parsedUrl.parameters["error_description"] ?: localizedString(Res.string.trakt_authorization_denied) clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = errorDescription, @@ -213,7 +218,7 @@ object TraktAuthRepository { val code = parsedUrl.parameters["code"].orEmpty().trim() if (code.isBlank()) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_missing_auth_code), @@ -225,7 +230,7 @@ object TraktAuthRepository { val callbackState = parsedUrl.parameters["state"].orEmpty().trim() if (!expectedState.isNullOrBlank() && callbackState != expectedState) { clearPendingAuthorization() - persist() + persist(profileId) publish( isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_callback_state), @@ -233,10 +238,10 @@ object TraktAuthRepository { return } - exchangeAuthorizationCode(code) + exchangeAuthorizationCode(code, profileId) } - private suspend fun exchangeAuthorizationCode(code: String) { + private suspend fun exchangeAuthorizationCode(code: String, profileId: Int = currentProfileId) { val body = json.encodeToString( TraktAuthorizationCodeRequest( code = code, @@ -259,7 +264,7 @@ object TraktAuthRepository { if (response == null) { clearPendingAuthorization() - persist() + persist(profileId) publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed)) return } @@ -270,7 +275,7 @@ object TraktAuthRepository { if (parsed == null) { clearPendingAuthorization() - persist() + persist(profileId) publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) return } @@ -284,8 +289,8 @@ object TraktAuthRepository { pendingAuthorizationState = null, pendingAuthorizationStartedAtMillis = null, ) - persist() - refreshUserSettings() + persist(profileId) + refreshUserSettings(profileId) publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -293,7 +298,7 @@ object TraktAuthRepository { ) } - private suspend fun disconnect() { + private suspend fun disconnect(profileId: Int = currentProfileId) { publish(isLoading = true, errorMessage = null) val token = authState.accessToken?.takeIf { it.isNotBlank() } @@ -317,9 +322,9 @@ object TraktAuthRepository { } } - TraktCredentialSync.deleteRemote() + TraktCredentialSync.deleteRemote(profileId) authState = TraktAuthState() - persist() + persist(profileId) publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_disconnected_status), @@ -327,9 +332,8 @@ object TraktAuthRepository { ) } - private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock { + private suspend fun refreshTokenIfNeeded(force: Boolean, profileId: Int = currentProfileId): Boolean = refreshMutex.withLock { if (!hasRequiredCredentials()) return@withLock false - val profileId = ProfileRepository.activeProfileId val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return@withLock false @@ -391,14 +395,14 @@ object TraktAuthRepository { createdAt = parsed.createdAt, expiresIn = parsed.expiresIn, ) - persist() + persist(profileId) publish() true } private suspend fun invalidateCredentials(profileId: Int) { authState = TraktAuthState() - persist() + persist(profileId) publish( isLoading = false, statusMessage = null, @@ -407,9 +411,11 @@ object TraktAuthRepository { TraktCredentialSync.deleteRemote(profileId) } - private fun loadFromDisk() { + private fun loadFromDisk(profileId: Int) { + currentProfileId = profileId + profileGeneration += 1L hasLoaded = true - val payload = TraktAuthStorage.loadPayload().orEmpty().trim() + val payload = TraktAuthStorage.loadPayload(profileId).orEmpty().trim() authState = if (payload.isBlank()) { TraktAuthState() } else { @@ -460,8 +466,8 @@ object TraktAuthRepository { ) } - private fun persist() { - TraktAuthStorage.savePayload(json.encodeToString(authState)) + private fun persist(profileId: Int = currentProfileId) { + TraktAuthStorage.savePayload(profileId, json.encodeToString(authState)) } private fun buildAuthorizationUrl(state: String): String { @@ -486,6 +492,7 @@ object TraktAuthRepository { return nowSeconds >= (expiresAtSeconds - 60) } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } } internal enum class TraktTokenRefreshResponseAction { @@ -549,4 +556,3 @@ private data class TraktUserDto( private data class TraktUserIdsDto( val slug: String? = null, ) - private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt index 3527be204..12cb2420f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.kt @@ -1,6 +1,6 @@ package com.nuvio.app.features.trakt internal expect object TraktAuthStorage { - fun loadPayload(): String? - fun savePayload(payload: String) + fun loadPayload(profileId: Int): String? + fun savePayload(profileId: Int, payload: String) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 27ee20761..cb2cae032 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -270,7 +270,7 @@ object WatchedRepository { ) suspend fun pullFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, @@ -283,7 +283,7 @@ object WatchedRepository { } suspend fun forceSnapshotRefreshFromServer(profileId: Int) { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() refreshForSource( profileId = profileId, @@ -300,7 +300,7 @@ object WatchedRepository { source: WatchProgressSource, forceSnapshot: Boolean = true, ): Boolean { - TraktAuthRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded(profileId) TraktSettingsRepository.ensureLoaded() if (ProfileRepository.activeProfileId != profileId) { log.d { "Skipping watched refresh for inactive profile $profileId" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index aca40d7fe..0314e65a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -24,6 +24,7 @@ enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSortMode { DEFAULT, STREAMING_STYLE, + SPLIT_UPCOMING, } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt index 7320a800f..b458d9e1b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/NuvioNavigator.kt @@ -9,7 +9,6 @@ internal class NuvioNavigator( private val onExternalNavigate: ((AppRoute, launchSingleTop: Boolean) -> Unit)? = null, private val onExternalBack: (() -> Unit)? = null, private val onExternalReplace: ((AppRoute) -> Unit)? = null, - private val onRouteRemoved: (AppRoute) -> Unit = {}, ) { val currentRoute: AppRoute? get() = backStack.lastOrNull() as? AppRoute @@ -37,12 +36,7 @@ internal class NuvioNavigator( val firstRemovedIndex = if (resolvedOptions.popUpToInclusive) targetIndex else targetIndex + 1 if (firstRemovedIndex <= backStack.lastIndex) { - val removedRoutes = backStack - .subList(firstRemovedIndex, backStack.size) - .filterIsInstance() - .toList() backStack.subList(firstRemovedIndex, backStack.size).clear() - removedRoutes.forEach(onRouteRemoved) } } } @@ -54,7 +48,7 @@ internal class NuvioNavigator( fun popBackStack(expectedRoute: AppRoute? = null): Boolean { if (expectedRoute != null && currentRoute != expectedRoute) return false if (backStack.size > 1) { - (backStack.removeAt(backStack.lastIndex) as? AppRoute)?.let(onRouteRemoved) + backStack.removeAt(backStack.lastIndex) return true } onExternalBack?.invoke() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt new file mode 100644 index 000000000..4ba7d2e8d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/navigation/RouteDisposalNavEntryDecorator.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.navigation + +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator + +internal class RouteDisposalNavEntryDecorator private constructor( + private val registry: RouteDisposalRegistry, +) : NavEntryDecorator( + onPop = registry::dispose, + decorate = { entry -> entry.Content() }, +) { + constructor(onDispose: (T) -> Unit) : this(RouteDisposalRegistry(onDispose)) + + fun register(key: T, entry: NavEntry): NavEntry = entry.also { + registry.register(contentKey = entry.contentKey, key = key) + } +} + +internal class RouteDisposalRegistry( + private val onDispose: (T) -> Unit, +) { + private val keysByContentKey = mutableMapOf() + + fun register(contentKey: Any, key: T) { + val existingKey = keysByContentKey[contentKey] + check(existingKey == null || existingKey == key) { + "Navigation content key $contentKey is already registered to a different route" + } + keysByContentKey[contentKey] = key + } + + fun dispose(contentKey: Any) { + keysByContentKey.remove(contentKey)?.let(onDispose) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 20df1e2e2..5ad99e0fe 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -10,6 +10,7 @@ import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.nextUpDismissKey @@ -162,6 +163,72 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `split upcoming mode moves only unaired next up episodes and sorts them by release`() { + val nowEpochMs = requireNotNull(parseReleaseDateToEpochMs("2026-07-19T12:00:00Z")) + val inProgress = progressEntry( + videoId = "movie-1", + title = "Movie", + lastUpdatedEpochMs = 500L, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + ).toContinueWatchingItem() + val aired = continueWatchingItem( + videoId = "aired:1:2", + subtitle = "S1E2 • Aired", + ).copy(released = "2026-07-19T11:59:59Z") + val unknownRelease = continueWatchingItem( + videoId = "unknown:1:2", + subtitle = "S1E2 • Unknown", + ) + val laterUpcoming = continueWatchingItem( + videoId = "later:1:2", + subtitle = "S1E2 • Later", + ).copy(released = "2026-07-21T00:00:00Z") + val soonerUpcoming = continueWatchingItem( + videoId = "sooner:1:2", + subtitle = "S1E2 • Sooner", + ).copy(released = "2026-07-20T00:00:00Z") + + val (main, upcoming) = splitUpcomingItems( + items = listOf(laterUpcoming, inProgress, aired, unknownRelease, soonerUpcoming), + mode = ContinueWatchingSortMode.SPLIT_UPCOMING, + nowEpochMs = nowEpochMs, + ) + + assertEquals( + listOf("movie-1", "aired:1:2", "unknown:1:2"), + main.map(ContinueWatchingItem::videoId), + ) + assertEquals( + listOf("sooner:1:2", "later:1:2"), + upcoming.map(ContinueWatchingItem::videoId), + ) + } + + @Test + fun `non split sort modes keep upcoming episodes in continue watching`() { + val futureItem = continueWatchingItem( + videoId = "future:1:2", + subtitle = "S1E2 • Future", + ).copy(released = "2099-01-01T00:00:00Z") + + listOf( + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.STREAMING_STYLE, + ).forEach { mode -> + val (main, upcoming) = splitUpcomingItems( + items = listOf(futureItem), + mode = mode, + nowEpochMs = 0L, + ) + + assertEquals(listOf(futureItem), main) + assertTrue(upcoming.isEmpty()) + } + } + @Test fun `build home continue watching items enriches cloud title from library file`() { val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv") diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt new file mode 100644 index 000000000..bbfdf90bb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt @@ -0,0 +1,153 @@ +package com.nuvio.app.features.library + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LibraryDisplaySettingsTest { + + @Test + fun `local default resolves to recently added while Trakt uses rank order`() { + assertEquals( + LibrarySortOption.ADDED_DESC, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySortOption.DEFAULT, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT), + ) + + val input = listOf( + item("ranked-second", savedAt = 3L, traktRank = 2), + item("unranked", savedAt = 4L), + item("ranked-first", savedAt = 1L, traktRank = 1), + item("ranked-first-newer", savedAt = 2L, traktRank = 1), + ) + assertEquals( + listOf("ranked-first-newer", "ranked-first", "ranked-second", "unranked"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT).map { it.id }, + ) + assertEquals( + listOf("unranked", "ranked-second", "ranked-first-newer", "ranked-first"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `added sorting works in both directions`() { + val input = listOf( + item("middle", savedAt = 2L), + item("oldest", savedAt = 1L), + item("newest", savedAt = 3L), + ) + + assertEquals( + listOf("newest", "middle", "oldest"), + sortLibraryItems(input, LibrarySortOption.ADDED_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("oldest", "middle", "newest"), + sortLibraryItems(input, LibrarySortOption.ADDED_ASC, LibrarySourceMode.TRAKT).map { it.id }, + ) + } + + @Test + fun `title sorting ignores leading English articles`() { + val input = listOf( + item("batman", name = "The Batman"), + item("arrival", name = "Arrival"), + item("quiet", name = "A Quiet Place"), + ) + + assertEquals( + listOf("arrival", "batman", "quiet"), + sortLibraryItems(input, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("quiet", "batman", "arrival"), + sortLibraryItems(input, LibrarySortOption.TITLE_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `horizontal sections sort independently without changing section order`() { + val sections = listOf( + LibrarySection( + type = "movie", + displayTitle = "Movies", + items = listOf(item("z", name = "Zulu"), item("a", name = "Alpha")), + ), + LibrarySection( + type = "series", + displayTitle = "Series", + items = listOf(item("y", type = "series", name = "Yellow"), item("b", type = "series", name = "Beta")), + ), + ) + + val sorted = sortLibrarySections(sections, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL) + + assertEquals(listOf("movie", "series"), sorted.map { it.type }) + assertEquals(listOf("a", "z"), sorted[0].items.map { it.id }) + assertEquals(listOf("b", "y"), sorted[1].items.map { it.id }) + } + + @Test + fun `vertical Trakt projection selects one list then filters and sorts its items`() { + val watchlist = LibrarySection( + type = "watchlist", + displayTitle = "Watchlist", + items = listOf( + item("z", name = "Zulu"), + item("series", type = "series", name = "Series"), + item("a", name = "Alpha"), + ), + ) + val personal = LibrarySection( + type = "personal:1", + displayTitle = "Favorites", + items = listOf(item("favorite", name = "Favorite")), + ) + + val projection = buildLibraryVerticalProjection( + sections = listOf(watchlist, personal), + sourceMode = LibrarySourceMode.TRAKT, + selectedSectionKey = "missing", + selectedType = "movie", + sortOption = LibrarySortOption.TITLE_ASC, + ) + + assertEquals("watchlist", projection.selectedSectionKey) + assertEquals(listOf("movie", "series"), projection.availableTypes) + assertEquals("movie", projection.selectedType) + assertEquals(listOf("a", "z"), projection.entries.map { it.item.id }) + assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type }) + } + + @Test + fun `display settings payload round trips and invalid values fall back safely`() { + val state = LibraryDisplaySettingsUiState( + layoutMode = LibraryLayoutMode.VERTICAL, + sortOption = LibrarySortOption.TITLE_DESC, + ) + + assertEquals(state, decodeLibraryDisplaySettings(encodeLibraryDisplaySettings(state))) + assertEquals( + LibraryDisplaySettingsUiState(), + decodeLibraryDisplaySettings("""{"layout_mode":"unknown","sort_option":"unknown"}"""), + ) + } + + private fun item( + id: String, + type: String = "movie", + name: String = id, + savedAt: Long = 0L, + traktRank: Int? = null, + ): LibraryItem = + LibraryItem( + id = id, + type = type, + name = name, + savedAtEpochMs = savedAt, + traktRank = traktRank, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pMagnetTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pMagnetTest.kt new file mode 100644 index 000000000..fba6f0d33 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pMagnetTest.kt @@ -0,0 +1,32 @@ +package com.nuvio.app.features.p2p + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class P2pMagnetTest { + @Test + fun v1HashAndTrackersAreCanonicalEncodedAndDeduplicated() { + val hash = "ABCDEF0123456789ABCDEF0123456789ABCDEF01" + val tracker = "udp://tracker.example:80/announce?key=hello world" + val magnet = buildP2pMagnetUri(hash, listOf("", tracker, " ", tracker)) + + assertTrue(magnet.startsWith("magnet:?xt=urn:btih:${hash.lowercase()}")) + assertEquals(1, magnet.windowed("&tr=".length).count { it == "&tr=" }) + assertTrue(magnet.endsWith("udp%3A%2F%2Ftracker.example%3A80%2Fannounce%3Fkey%3Dhello%20world")) + } + + @Test + fun v2HashUsesMultihashTopic() { + val hash = "a".repeat(64) + assertTrue(buildP2pMagnetUri(hash, emptyList()).contains("xt=urn:btmh:1220$hash")) + } + + @Test + fun invalidHashIsRejectedBeforeNativeWork() { + assertFailsWith { + buildP2pMagnetUri("not-a-hash", emptyList()) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pTelemetryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pTelemetryTest.kt new file mode 100644 index 000000000..42fe41fe8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/p2p/P2pTelemetryTest.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.p2p + +import com.nuvio.app.features.player.p2pInitialLoadingProgress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class P2pTelemetryTest { + @Test + fun torrentStatsAreVisibleByDefault() { + assertFalse(P2pSettingsUiState().hideTorrentStats) + } + + @Test + fun connectingStateCarriesStartupTelemetry() { + val state = P2pStreamingState.Connecting( + phase = "add_magnet", + downloadSpeed = 2_000_000L, + peers = 12, + seeds = 7, + ) + + assertEquals("add_magnet", state.phase) + assertEquals(2_000_000L, state.downloadSpeed) + assertEquals(12, state.peers) + assertEquals(7, state.seeds) + } + + @Test + fun initialProgressUsesReadinessStagesWithoutCompletingDuringBuffering() { + val target = 5_242_880L + + assertEquals(0f, p2pInitialLoadingProgress(0L, 0L, 0L)) + assertEquals(0.225f, p2pInitialLoadingProgress(0L, target, 0L)) + assertEquals(0.375f, p2pInitialLoadingProgress(0L, target, target)) + assertEquals(0.85f, p2pInitialLoadingProgress(5_000L, target, target)) + assertEquals(0.95f, p2pInitialLoadingProgress(15_000L, Long.MAX_VALUE, Long.MAX_VALUE)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt new file mode 100644 index 000000000..766ee7b68 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/navigation/RouteDisposalRegistryTest.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class RouteDisposalRegistryTest { + @Test + fun disposesRegisteredRouteExactlyOnceWhenNav3ReportsPop() { + val disposedRoutes = mutableListOf() + val registry = RouteDisposalRegistry(disposedRoutes::add) + + registry.register(contentKey = "stream-entry", key = "stream-route") + + assertTrue(disposedRoutes.isEmpty()) + + registry.dispose(contentKey = "stream-entry") + registry.dispose(contentKey = "stream-entry") + + assertEquals(listOf("stream-route"), disposedRoutes) + } + + @Test + fun rejectsAContentKeySharedByDifferentRoutes() { + val registry = RouteDisposalRegistry {} + registry.register(contentKey = "shared-entry", key = "first-route") + + assertFailsWith { + registry.register(contentKey = "shared-entry", key = "second-route") + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt similarity index 72% rename from composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt rename to composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt index df670f29c..e267bc88f 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt +++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.asStateFlow actual object P2pStreamingEngine { private val _state = MutableStateFlow(P2pStreamingState.Idle) actual val state: StateFlow = _state.asStateFlow() + private val _cacheState = MutableStateFlow(P2pCacheUiState()) + actual val cacheState: StateFlow = _cacheState.asStateFlow() actual suspend fun startStream(request: P2pStreamRequest): String { val message = "P2P streaming is not available on this platform" @@ -14,6 +16,9 @@ actual object P2pStreamingEngine { throw P2pStreamingException(message) } + actual suspend fun clearCache(): P2pCacheClearResult = + P2pCacheClearResult(reclaimedBytes = 0L, remainingBytes = 0L, protectedBytes = 0L) + actual fun stopStream() { _state.value = P2pStreamingState.Idle } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt index b86fa9f4b..526130b96 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt @@ -5,7 +5,7 @@ actual object AppFeaturePolicy { actual val supportersContributorsPageEnabled: Boolean = true actual val accountDeletionEnabled: Boolean = false actual val personalMediaAddonCopyEnabled: Boolean = false - actual val p2pEnabled: Boolean = false + actual val p2pEnabled: Boolean = true actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP actual val heroTrailerPlaybackSupported: Boolean = false actual val inAppUpdaterEnabled: Boolean = false diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt new file mode 100644 index 000000000..29363c636 --- /dev/null +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.ios.kt @@ -0,0 +1,563 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.nuvio.app.features.p2p + +import cnames.structs.nuvio_engine +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_DISK_CACHE_RECLAIMED +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_STREAM_PREPARED +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_STREAM_STOPPED +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_TORRENT_ERROR +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_EVENT_TORRENT_METADATA_READY +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_STATUS_NO_EVENT +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_STATUS_OK +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_BALANCED +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_FAST +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_TORRENT_PROFILE_SOFT +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_UPLOAD_DISABLED +import com.nuvio.app.features.p2p.native.NUVIO_ENGINE_UPLOAD_UNLIMITED +import com.nuvio.app.features.p2p.native.nuvio_engine_add_torrent +import com.nuvio.app.features.p2p.native.nuvio_engine_config +import com.nuvio.app.features.p2p.native.nuvio_engine_config_init_sized +import com.nuvio.app.features.p2p.native.nuvio_engine_create +import com.nuvio.app.features.p2p.native.nuvio_engine_destroy +import com.nuvio.app.features.p2p.native.nuvio_engine_event +import com.nuvio.app.features.p2p.native.nuvio_engine_event_init_sized +import com.nuvio.app.features.p2p.native.nuvio_engine_get_stats +import com.nuvio.app.features.p2p.native.nuvio_engine_get_stream_stats +import com.nuvio.app.features.p2p.native.nuvio_engine_poll_event +import com.nuvio.app.features.p2p.native.nuvio_engine_prepare_stream +import com.nuvio.app.features.p2p.native.nuvio_engine_reclaim_disk_cache +import com.nuvio.app.features.p2p.native.nuvio_engine_stats +import com.nuvio.app.features.p2p.native.nuvio_engine_stats_init_sized +import com.nuvio.app.features.p2p.native.nuvio_engine_status_message +import com.nuvio.app.features.p2p.native.nuvio_engine_stop_stream +import com.nuvio.app.features.p2p.native.nuvio_engine_stream_request +import com.nuvio.app.features.p2p.native.nuvio_engine_stream_request_init_sized +import com.nuvio.app.features.p2p.native.nuvio_engine_stream_stats +import com.nuvio.app.features.p2p.native.nuvio_engine_stream_stats_init_sized +import com.nuvio.app.features.p2p.native.nuvio_engine_torrent_request +import com.nuvio.app.features.p2p.native.nuvio_engine_torrent_request_init_sized +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CPointerVar +import kotlinx.cinterop.ULongVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.cstr +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.sizeOf +import kotlinx.cinterop.toKString +import kotlinx.cinterop.value +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import platform.Foundation.NSFileManager +import platform.Foundation.NSHomeDirectory +import platform.Foundation.NSLog + +private const val StatsPollIntervalMs = 250L +private const val StartupStatsPollIntervalMs = 1_000L +private const val MemoryCacheCapacityBytes = 64L * 1024L * 1024L + +actual object P2pStreamingEngine { + private data class EngineConfigurationKey( + val uploadEnabled: Boolean, + val torrentProfile: P2pTorrentProfile, + val diskCacheCapacityBytes: Long, + ) + + private data class NativeEvent( + val type: UInt, + val requestId: ULong, + val torrentId: String?, + val message: String?, + val fileIndex: UInt?, + val fileSize: ULong, + val streamId: String?, + val streamUrl: String?, + ) + + private data class NativeStream( + val id: String, + val url: String, + val torrentId: String, + val fileIndex: Int, + val fileSize: Long, + ) + + private data class AggregateStats( + val peers: Int, + val seeds: Int, + val downloadSpeed: Long, + val uploadSpeed: Long, + val payloadDownloaded: Long, + val diskUsed: Long, + val diskProtected: Long, + ) + + private data class StreamStats( + val fileSize: Long, + val contiguousReady: Long, + val verified: Long, + val delivered: Long, + ) + + private val _state = MutableStateFlow(P2pStreamingState.Idle) + actual val state: StateFlow = _state.asStateFlow() + private val _cacheState = MutableStateFlow(P2pCacheUiState()) + actual val cacheState: StateFlow = _cacheState.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val lifecycleMutex = Mutex() + private var engine: CPointer? = null + private var engineConfigurationKey: EngineConfigurationKey? = null + private var currentTorrentId: String? = null + private var currentStream: NativeStream? = null + private var statsJob: Job? = null + private var generation = 0L + private val knownTorrentIds = mutableSetOf() + + actual suspend fun startStream(request: P2pStreamRequest): String = lifecycleMutex.withLock { + stopLocked(shutdownEngine = false) + generation += 1 + val streamGeneration = generation + _state.value = P2pStreamingState.Connecting() + var phase = "build_magnet" + var startupStatsJob: Job? = null + var preparedStream: NativeStream? = null + try { + val magnet = buildP2pMagnetUri( + request.infoHash, + (DefaultTrackers + request.trackers).distinct(), + ) + phase = "ensure_engine" + val activeEngine = ensureEngine() + val baseline = readAggregateStats(activeEngine).payloadDownloaded + startupStatsJob = startStartupStatsPolling(activeEngine, streamGeneration) { phase } + + phase = "add_magnet" + val canonicalHash = canonicalP2pInfoHash(request.infoHash) + val torrentId = if (canonicalHash in knownTorrentIds) { + canonicalHash + } else { + addMagnet(activeEngine, magnet).also(knownTorrentIds::add) + } + currentCoroutineContext().ensureActive() + + phase = "prepare_stream" + val stream = prepareStream( + activeEngine, + torrentId, + request.fileIdx, + request.filename, + ) + preparedStream = stream + currentCoroutineContext().ensureActive() + currentTorrentId = torrentId + currentStream = stream + phase = "attach_route" + + val aggregate = readAggregateStats(activeEngine) + publishStreaming(streamGeneration, stream, aggregate, baseline, null) + startStatsPolling(activeEngine, stream, streamGeneration, baseline) + log("stream ready fileIndex=${stream.fileIndex} fileBytes=${stream.fileSize}") + stream.url + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + preparedStream?.let { runCatching { stopNativeStream(engine, it.id) } } + } + currentTorrentId = null + currentStream = null + _state.value = P2pStreamingState.Idle + throw cancellation + } catch (error: Throwable) { + preparedStream?.let { runCatching { stopNativeStream(engine, it.id) } } + currentTorrentId = null + currentStream = null + val message = error.message ?: "Unable to start torrent stream" + _state.value = P2pStreamingState.Error(message) + log("stream failed phase=$phase error=$message") + throw P2pStreamingException(message) + } finally { + startupStatsJob?.cancel() + } + } + + actual suspend fun clearCache(): P2pCacheClearResult = lifecycleMutex.withLock { + check(_state.value !is P2pStreamingState.Connecting && + _state.value !is P2pStreamingState.Streaming) { + "Torrent cache cannot be cleared during active playback" + } + _cacheState.value = _cacheState.value.copy(isClearing = true) + try { + val activeEngine = ensureEngine() + val before = readAggregateStats(activeEngine) + reclaimDiskCache(activeEngine) + val after = readAggregateStats(activeEngine) + updateCacheState(after) + P2pCacheClearResult( + reclaimedBytes = (before.diskUsed - after.diskUsed).coerceAtLeast(0L), + remainingBytes = after.diskUsed, + protectedBytes = after.diskProtected, + ) + } finally { + _cacheState.value = _cacheState.value.copy(isClearing = false) + } + } + + actual fun stopStream() { + scope.launch { + lifecycleMutex.withLock { stopLocked(shutdownEngine = false) } + } + } + + actual fun shutdown() { + scope.launch { + lifecycleMutex.withLock { stopLocked(shutdownEngine = true) } + } + } + + private suspend fun stopLocked(shutdownEngine: Boolean) { + generation += 1 + statsJob?.cancel() + statsJob = null + val stream = currentStream + currentStream = null + currentTorrentId = null + _state.value = P2pStreamingState.Idle + stream?.let { runCatching { stopNativeStream(engine, it.id) } } + if (shutdownEngine) closeEngine() + } + + private fun startStartupStatsPolling( + activeEngine: CPointer, + streamGeneration: Long, + phase: () -> String, + ): Job = scope.launch { + while (isActive && generation == streamGeneration) { + runCatching { readAggregateStats(activeEngine) }.getOrNull()?.let { stats -> + if (_state.value is P2pStreamingState.Connecting) { + _state.value = P2pStreamingState.Connecting( + phase = phase(), + downloadSpeed = stats.downloadSpeed, + uploadSpeed = stats.uploadSpeed, + peers = stats.peers, + seeds = stats.seeds, + ) + updateCacheState(stats) + } + } + delay(StartupStatsPollIntervalMs) + } + } + + private fun startStatsPolling( + activeEngine: CPointer, + stream: NativeStream, + streamGeneration: Long, + payloadBaseline: Long, + ) { + statsJob?.cancel() + statsJob = scope.launch { + while (isActive && generation == streamGeneration) { + try { + val aggregate = readAggregateStats(activeEngine) + val route = readStreamStats(activeEngine, stream.id) + updateCacheState(aggregate) + publishStreaming(streamGeneration, stream, aggregate, payloadBaseline, route) + } catch (error: Throwable) { + if (error is CancellationException) throw error + if (generation == streamGeneration) { + _state.value = P2pStreamingState.Error( + error.message ?: "Torrent stream stopped unexpectedly" + ) + } + return@launch + } + delay(StatsPollIntervalMs) + } + } + } + + private fun publishStreaming( + streamGeneration: Long, + stream: NativeStream, + aggregate: AggregateStats, + payloadBaseline: Long, + route: StreamStats?, + ) { + if (generation != streamGeneration || currentStream?.id != stream.id) return + _state.value = P2pStreamingState.Streaming( + localUrl = stream.url, + downloadSpeed = aggregate.downloadSpeed, + uploadSpeed = aggregate.uploadSpeed, + peers = aggregate.peers, + seeds = aggregate.seeds, + bufferProgress = ratio(route?.contiguousReady ?: 0L, route?.fileSize ?: stream.fileSize), + totalProgress = ratio(route?.verified ?: 0L, route?.fileSize ?: stream.fileSize), + downloadedBytes = (aggregate.payloadDownloaded - payloadBaseline).coerceAtLeast(0L), + verifiedBytes = route?.verified ?: 0L, + deliveredBytes = route?.delivered ?: 0L, + ) + } + + private suspend fun ensureEngine(): CPointer { + P2pSettingsRepository.ensureLoaded() + val settings = P2pSettingsRepository.uiState.value + val configuration = EngineConfigurationKey( + uploadEnabled = settings.enableUpload, + torrentProfile = settings.torrentProfile, + diskCacheCapacityBytes = settings.cacheSize.bytes, + ) + engine?.takeIf { engineConfigurationKey == configuration }?.let { return it } + closeEngine() + + val stateDirectory = "${NSHomeDirectory()}/Library/Application Support/NuvioEngine/state" + val cacheDirectory = "${NSHomeDirectory()}/Library/Caches/NuvioEngine/payload" + createDirectory(stateDirectory) + createDirectory(cacheDirectory) + val created = memScoped { + val config = alloc() + nuvio_engine_config_init_sized(config.ptr, sizeOf().toUInt()) + config.data_directory = stateDirectory.cstr.getPointer(this) + config.cache_directory = cacheDirectory.cstr.getPointer(this) + config.memory_cache_capacity_bytes = MemoryCacheCapacityBytes.toULong() + config.disk_cache_capacity_bytes = configuration.diskCacheCapacityBytes.toULong() + config.upload_mode = if (configuration.uploadEnabled) { + NUVIO_ENGINE_UPLOAD_UNLIMITED + } else { + NUVIO_ENGINE_UPLOAD_DISABLED + } + config.upload_limit_bytes_per_second = 0uL + config.stream_inactivity_timeout_milliseconds = 0u + config.warm_torrent_timeout_milliseconds = 60_000u + config.tls_ca_bundle_path = null + config.torrent_profile = when (configuration.torrentProfile) { + P2pTorrentProfile.SOFT -> NUVIO_ENGINE_TORRENT_PROFILE_SOFT + P2pTorrentProfile.BALANCED -> NUVIO_ENGINE_TORRENT_PROFILE_BALANCED + P2pTorrentProfile.FAST -> NUVIO_ENGINE_TORRENT_PROFILE_FAST + } + val output = alloc>() + checkStatus(nuvio_engine_create(config.ptr, output.ptr)) + output.value ?: throw P2pStreamingException("Nuvio Engine returned an empty handle") + } + engine = created + engineConfigurationKey = configuration + log("engine created configuration=$configuration") + return created + } + + private fun closeEngine() { + val activeEngine = engine ?: return + engine = null + engineConfigurationKey = null + knownTorrentIds.clear() + nuvio_engine_destroy(activeEngine) + } + + private suspend fun addMagnet(activeEngine: CPointer, magnet: String): String { + val requestId = memScoped { + val request = alloc() + nuvio_engine_torrent_request_init_sized( + request.ptr, + sizeOf().toUInt(), + ) + request.magnet_uri = magnet.cstr.getPointer(this) + request.source_type = 0u + val output = alloc() + checkStatus(nuvio_engine_add_torrent(activeEngine, request.ptr, output.ptr)) + output.value + } + return awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_TORRENT_METADATA_READY) + .torrentId + ?: throw P2pStreamingException("Torrent metadata event omitted its identifier") + } + + private suspend fun prepareStream( + activeEngine: CPointer, + torrentId: String, + fileIndex: Int?, + filename: String?, + ): NativeStream { + val requestId = memScoped { + val request = alloc() + nuvio_engine_stream_request_init_sized( + request.ptr, + sizeOf().toUInt(), + ) + request.torrent_id = torrentId.cstr.getPointer(this) + request.file_index = fileIndex?.toUInt() ?: UInt.MAX_VALUE + request.filename_hint = filename?.cstr?.getPointer(this) + val output = alloc() + checkStatus(nuvio_engine_prepare_stream(activeEngine, request.ptr, output.ptr)) + output.value + } + val event = awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_STREAM_PREPARED) + return NativeStream( + id = event.streamId + ?: throw P2pStreamingException("Stream event omitted its identifier"), + url = event.streamUrl + ?: throw P2pStreamingException("Stream event omitted its URL"), + torrentId = event.torrentId ?: torrentId, + fileIndex = event.fileIndex?.toInt() ?: fileIndex ?: -1, + fileSize = event.fileSize.toLong(), + ) + } + + private suspend fun stopNativeStream(activeEngine: CPointer?, streamId: String) { + if (activeEngine == null) return + val requestId = memScoped { + val output = alloc() + checkStatus(nuvio_engine_stop_stream(activeEngine, streamId, output.ptr)) + output.value + } + awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_STREAM_STOPPED) + } + + private suspend fun reclaimDiskCache(activeEngine: CPointer) { + val requestId = memScoped { + val output = alloc() + checkStatus(nuvio_engine_reclaim_disk_cache(activeEngine, 0uL, output.ptr)) + output.value + } + awaitEvent(activeEngine, requestId, NUVIO_ENGINE_EVENT_DISK_CACHE_RECLAIMED) + } + + private suspend fun awaitEvent( + activeEngine: CPointer, + requestId: ULong, + expectedType: UInt, + ): NativeEvent { + while (true) { + currentCoroutineContext().ensureActive() + when (val event = pollEvent(activeEngine)) { + null -> delay(20L) + else -> { + if (event.requestId == requestId && event.type == NUVIO_ENGINE_EVENT_TORRENT_ERROR) { + throw P2pStreamingException(event.message ?: "Torrent engine command failed") + } + if (event.requestId == requestId && event.type == expectedType) return event + } + } + } + } + + private fun pollEvent(activeEngine: CPointer): NativeEvent? = memScoped { + val event = alloc() + nuvio_engine_event_init_sized(event.ptr, sizeOf().toUInt()) + val status = nuvio_engine_poll_event(activeEngine, event.ptr) + if (status == NUVIO_ENGINE_STATUS_NO_EVENT) return@memScoped null + checkStatus(status) + NativeEvent( + type = event.type, + requestId = event.request_id, + torrentId = event.torrent_id.toKString().ifBlank { null }, + message = event.message.toKString().ifBlank { null }, + fileIndex = event.file_index.takeUnless { it == UInt.MAX_VALUE }, + fileSize = event.file_size, + streamId = event.stream_id.toKString().ifBlank { null }, + streamUrl = event.stream_url.toKString().ifBlank { null }, + ) + } + + private fun readAggregateStats(activeEngine: CPointer): AggregateStats = memScoped { + val stats = alloc() + nuvio_engine_stats_init_sized(stats.ptr, sizeOf().toUInt()) + checkStatus(nuvio_engine_get_stats(activeEngine, stats.ptr)) + AggregateStats( + peers = stats.connected_peers.toInt(), + seeds = stats.connected_seeds.toInt(), + downloadSpeed = stats.download_rate_bytes_per_second.toLong(), + uploadSpeed = stats.upload_rate_bytes_per_second.toLong(), + payloadDownloaded = stats.total_payload_download_bytes.toLong(), + diskUsed = stats.disk_cache_used_bytes.toLong(), + diskProtected = stats.disk_cache_protected_bytes.toLong(), + ) + } + + private fun readStreamStats( + activeEngine: CPointer, + streamId: String, + ): StreamStats = memScoped { + val stats = alloc() + nuvio_engine_stream_stats_init_sized( + stats.ptr, + sizeOf().toUInt(), + ) + checkStatus(nuvio_engine_get_stream_stats(activeEngine, streamId, stats.ptr)) + StreamStats( + fileSize = stats.file_size.toLong(), + contiguousReady = stats.contiguous_ready_bytes.toLong(), + verified = stats.verified_file_bytes.toLong(), + delivered = stats.delivered_bytes.toLong(), + ) + } + + private fun updateCacheState(stats: AggregateStats) { + _cacheState.value = _cacheState.value.copy( + usedBytes = stats.diskUsed, + protectedBytes = stats.diskProtected, + hasMeasurement = true, + ) + } + + private fun checkStatus(status: UInt) { + if (status == NUVIO_ENGINE_STATUS_OK) return + val message = nuvio_engine_status_message(status)?.toKString() + ?: "Nuvio Engine status $status" + throw P2pStreamingException(message) + } + + private fun createDirectory(path: String) { + val manager = NSFileManager.defaultManager + check(manager.createDirectoryAtPath( + path, + withIntermediateDirectories = true, + attributes = null, + error = null, + )) { "Could not create Nuvio Engine directory" } + } + + private fun ratio(value: Long, total: Long): Float = + if (total <= 0L) 0f else (value.toDouble() / total.toDouble()).toFloat().coerceIn(0f, 1f) + + private fun log(message: String) { + NSLog("NuvioP2PDiag: $message") + } + + private val DefaultTrackers = listOf( + "udp://zer0day.ch:1337/announce", + "udp://tracker.publictracker.xyz:6969/announce", + "udp://tracker.opentrackr.org:1337/announce", + "udp://open.demonii.com:1337/announce", + "udp://open.stealth.si:80/announce", + "http://tracker.renfei.net:8080/announce", + "udp://udp.tracker.projectk.org:23333/announce", + "udp://tracker.tryhackx.org:6969/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.theoks.net:6969/announce", + "udp://tracker.startwork.cv:1337/announce", + "udp://tracker.qu.ax:6969/announce", + "udp://tracker.plx.im:6969/announce", + "udp://tracker.nyaa.vc:6969/announce", + "udp://tracker.iperson.xyz:6969/announce", + "udp://tracker.gmi.gd:6969/announce", + "udp://tracker.fnix.net:6969/announce", + "udp://tracker.flatuslifir.is:6969/announce", + "udp://tracker.ducks.party:1984/announce", + "udp://tracker.bluefrog.pw:2710/announce", + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index c9a439664..80b095ddb 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -51,9 +51,11 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_trakt", "mdblist_use_letterboxd", "mdblist_use_audience", + "mdblist_use_mal", "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "library_display_settings_payload", "pending_watch_progress_source", "collection_mobile_settings_payload", "collections_payload", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt new file mode 100644 index 000000000..aeeef6f36 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object LibraryDisplaySettingsStorage { + private const val payloadKey = "library_display_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt index d05e79f14..3c4297207 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.ios.kt @@ -20,6 +20,7 @@ actual object MdbListSettingsStorage { private const val useTraktKey = "mdblist_use_trakt" private const val useLetterboxdKey = "mdblist_use_letterboxd" private const val useAudienceKey = "mdblist_use_audience" + private const val useMalKey = "mdblist_use_mal" private val syncKeys = listOf( enabledKey, apiKey, @@ -30,6 +31,7 @@ actual object MdbListSettingsStorage { useTraktKey, useLetterboxdKey, useAudienceKey, + useMalKey, ) actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) @@ -87,6 +89,12 @@ actual object MdbListSettingsStorage { saveBoolean(useAudienceKey, enabled) } + actual fun loadUseMal(): Boolean? = loadBoolean(useMalKey) + + actual fun saveUseMal(enabled: Boolean) { + saveBoolean(useMalKey, enabled) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) @@ -111,6 +119,7 @@ actual object MdbListSettingsStorage { loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } + loadUseMal()?.let { put(useMalKey, encodeSyncBoolean(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -127,5 +136,6 @@ actual object MdbListSettingsStorage { payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) + payload.decodeSyncBoolean(useMalKey)?.let(::saveUseMal) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.ios.kt index a7b9d9538..ed355c147 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/p2p/P2pSettingsStorage.ios.kt @@ -7,6 +7,8 @@ internal actual object P2pSettingsStorage { private const val p2pEnabledKey = "p2p_enabled" private const val enableUploadKey = "enable_upload" private const val hideTorrentStatsKey = "hide_torrent_stats" + private const val torrentProfileKey = "torrent_profile" + private const val cacheSizeKey = "cache_size" actual fun loadP2pEnabled(): Boolean? = loadBoolean(p2pEnabledKey) @@ -29,6 +31,18 @@ internal actual object P2pSettingsStorage { saveBoolean(hideTorrentStatsKey, enabled) } + actual fun loadTorrentProfile(): String? = loadString(torrentProfileKey) + + actual fun saveTorrentProfile(profile: String) { + saveString(torrentProfileKey, profile) + } + + actual fun loadCacheSize(): String? = loadString(cacheSizeKey) + + actual fun saveCacheSize(size: String) { + saveString(cacheSizeKey, size) + } + private fun loadBoolean(keyBase: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(keyBase) @@ -38,4 +52,11 @@ internal actual object P2pSettingsStorage { private fun saveBoolean(keyBase: String, value: Boolean) { NSUserDefaults.standardUserDefaults.setBool(value, forKey = ProfileScopedKey.of(keyBase)) } + + private fun loadString(keyBase: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(keyBase)) + + private fun saveString(keyBase: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(keyBase)) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 68697a344..9c0b0abe2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -51,8 +51,11 @@ actual fun PlatformPlayerSurface( useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, + initialPositionMs: Long?, + initialPositionRequestKey: String?, resizeMode: PlayerResizeMode, useNativeController: Boolean, + onInitialPositionHandled: (key: String, handled: Boolean) -> Unit, onControllerReady: (PlayerEngineController) -> Unit, onSnapshot: (PlayerPlaybackSnapshot) -> Unit, onError: (String?) -> Unit, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt index 3ccbaff4e..916fedb0f 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktAuthStorage.ios.kt @@ -6,10 +6,10 @@ import platform.Foundation.NSUserDefaults internal actual object TraktAuthStorage { private const val payloadKey = "trakt_auth_payload" - actual fun loadPayload(): String? = - NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + actual fun loadPayload(profileId: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey, profileId)) - actual fun savePayload(payload: String) { - NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + actual fun savePayload(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey, profileId)) } } diff --git a/composeApp/src/nativeInterop/cinterop/nuvioengine.def b/composeApp/src/nativeInterop/cinterop/nuvioengine.def new file mode 100644 index 000000000..096f3957f --- /dev/null +++ b/composeApp/src/nativeInterop/cinterop/nuvioengine.def @@ -0,0 +1,3 @@ +headers = nuvio_engine/nuvio_engine.h +package = com.nuvio.app.features.p2p.native +staticLibraries = libCNuvioEngine.a diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index f2fdc3528..00822e62e 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=99 -MARKETING_VERSION=0.3.0 +CURRENT_PROJECT_VERSION=100 +MARKETING_VERSION=0.3.1 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 2a2c1e595..f15b3beb2 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -405,6 +405,11 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SystemConfiguration, + ); PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.media; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; @@ -435,6 +440,11 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + SystemConfiguration, + ); PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; diff --git a/scripts/debug_logs.sh b/scripts/debug_logs.sh index 7f2014be3..4c262d414 100755 --- a/scripts/debug_logs.sh +++ b/scripts/debug_logs.sh @@ -12,6 +12,7 @@ # -s, --serial ADB device serial (optional, for multi-device) # -p, --package Android package/applicationId (default: debug build) # -t, --tag Additional logcat tag filter regex (default: all app) +# --p2p Focus on Nuvio Engine startup/playback diagnostics # -c, --clear Clear logcat buffer before streaming # -h, --help Show help # ───────────────────────────────────────────────────────────────────────────── @@ -21,6 +22,7 @@ PACKAGE="${NUVIO_LOG_PACKAGE:-com.nuviodebug.com}" SERIAL="" CLEAR_BUFFER=false TAG_FILTER="" +P2P_MODE=false STREAM_PID="" USER_QUIT=false @@ -33,6 +35,7 @@ LOG_FILE="${LOG_DIR}/debug_$(date +%Y%m%d_%H%M%S).log" # ── Noise suppression ─────────────────────────────────────────────────────── NOISE_TAGS='EGL_emulation|OpenGLRenderer|eglCodecCommon|goldfish|gralloc|hwcomposer|SurfaceFlinger|chatty|ConfigStore|libEGL|MediaCodec|AudioTrack|AudioFlinger|BufferQueueProducer|GraphicBufferSource|OMXClient' +P2P_TAGS='NuvioP2PDiag|NuvioPlayerDiag|P2pStreamingEngine|NuvioPlayer' # ── ANSI colour codes ─────────────────────────────────────────────────────── RST='\033[0m' @@ -62,6 +65,7 @@ Options: -s, --serial ADB device serial (optional) -p, --package Android package/applicationId (default: com.nuviodebug.com) -t, --tag Additional grep regex to filter log tags + --p2p Focus on engine phases, route telemetry, and player startup -c, --clear Clear logcat buffer before streaming -h, --help Show this help @@ -73,6 +77,8 @@ Examples: ./scripts/debug_logs.sh ./scripts/debug_logs.sh --serial emulator-5554 ./scripts/debug_logs.sh --package com.nuvio.app + ./scripts/debug_logs.sh --clear --p2p + ./scripts/debug_logs.sh --serial emulator-5554 --clear --p2p ./scripts/debug_logs.sh --tag 'MetaDetailsRepo|SeriesContent' ./scripts/debug_logs.sh --clear --tag 'Sync|Auth' EOF @@ -84,12 +90,21 @@ while [[ $# -gt 0 ]]; do -s|--serial) SERIAL="${2:-}"; shift 2 ;; -p|--package) PACKAGE="${2:-}"; shift 2 ;; -t|--tag) TAG_FILTER="${2:-}"; shift 2 ;; + --p2p) P2P_MODE=true; shift ;; -c|--clear) CLEAR_BUFFER=true; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage; exit 1 ;; esac done +if $P2P_MODE; then + if [[ -n "$TAG_FILTER" ]]; then + TAG_FILTER="${P2P_TAGS}|${TAG_FILTER}" + else + TAG_FILTER="$P2P_TAGS" + fi +fi + # ── ADB setup ──────────────────────────────────────────────────────────────── ADB=(adb) if [[ -n "$SERIAL" ]]; then @@ -104,6 +119,9 @@ fi DEVICE_MODEL=$("${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' || echo "unknown") ANDROID_VER=$("${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' || echo "?") +ANDROID_SDK=$("${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' || echo "?") +DEVICE_ABI=$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r' || echo "unknown") +DEVICE_SERIAL=$("${ADB[@]}" get-serialno 2>/dev/null | tr -d '\r' || echo "unknown") if $CLEAR_BUFFER; then "${ADB[@]}" logcat -c @@ -129,6 +147,22 @@ if [[ -z "$APP_UID" ]]; then APP_UID="$(uid_from_dumpsys || true)" fi +APP_VERSION=$("${ADB[@]}" shell dumpsys package "$PACKAGE" 2>/dev/null \ + | tr -d '\r' \ + | sed -nE 's/.*versionName=([^[:space:]]+).*/\1/p' \ + | head -n1) +APP_VERSION="${APP_VERSION:-unknown}" + +write_session_metadata() { + { + echo "# Nuvio Android diagnostic capture" + echo "# started=$(date '+%Y-%m-%dT%H:%M:%S%z')" + echo "# serial=${DEVICE_SERIAL} model=${DEVICE_MODEL} android=${ANDROID_VER} sdk=${ANDROID_SDK} abi=${DEVICE_ABI}" + echo "# package=${PACKAGE} version=${APP_VERSION} uid=${APP_UID:-unknown}" + echo "# p2pMode=${P2P_MODE} filter=${TAG_FILTER:-all-app-logs}" + } >> "$LOG_FILE" +} + # ── Colour-coding function ────────────────────────────────────────────────── colorize_line() { local line="$1" @@ -219,7 +253,9 @@ print_banner() { echo -e "${CLR_HEADER}${BOLD} ╚══════════════════════════════════════════════════════╝${RST}" echo -e "" echo -e " ${CLR_META}Device:${RST} ${CLR_ACCENT}${DEVICE_MODEL}${RST} ${CLR_META}(Android ${ANDROID_VER})${RST}" + echo -e " ${CLR_META}ABI / SDK:${RST} ${CLR_ACCENT}${DEVICE_ABI} / ${ANDROID_SDK}${RST}" echo -e " ${CLR_META}Package:${RST} ${CLR_ACCENT}${PACKAGE}${RST}" + echo -e " ${CLR_META}App version:${RST} ${CLR_ACCENT}${APP_VERSION}${RST}" echo -e " ${CLR_META}Log file:${RST} ${CLR_ACCENT}${LOG_FILE}${RST}" if [[ -n "$TAG_FILTER" ]]; then echo -e " ${CLR_META}Tag filter:${RST} ${CLR_ACCENT}${TAG_FILTER}${RST}" @@ -256,6 +292,7 @@ run_with_hotkeys() { c|C) "${ADB[@]}" logcat -c >/dev/null 2>&1 || true : > "$LOG_FILE" + write_session_metadata clear_terminal ;; q|Q) @@ -280,6 +317,7 @@ cleanup() { trap cleanup EXIT INT TERM # ── Main ───────────────────────────────────────────────────────────────────── +write_session_metadata print_banner if [[ -n "$APP_UID" ]]; then diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 000000000..a172a85d7 --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +set -euo pipefail + +from_ref="" +to_ref="HEAD" +repository="${GITHUB_REPOSITORY:-}" +offline=false +exclude_commits="${RELEASE_NOTES_EXCLUDE_COMMITS:-}" + +usage() { + echo "Usage: $0 --from [--to ] [--repository ] [--exclude ] [--offline]" >&2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) + from_ref="${2:-}" + shift 2 + ;; + --to) + to_ref="${2:-}" + shift 2 + ;; + --repository) + repository="${2:-}" + shift 2 + ;; + --exclude) + exclude_commits="${exclude_commits} ${2:-}" + shift 2 + ;; + --offline) + offline=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; + esac +done + +if [[ -z "$from_ref" ]]; then + usage + exit 1 +fi + +git cat-file -e "${from_ref}^{commit}" 2>/dev/null || { + echo "Unknown starting commit: ${from_ref}" >&2 + exit 1 +} +git cat-file -e "${to_ref}^{commit}" 2>/dev/null || { + echo "Unknown ending commit: ${to_ref}" >&2 + exit 1 +} + +is_excluded_hash() { + local commit="$1" + local excluded + for excluded in ${exclude_commits//,/ }; do + [[ -n "$excluded" ]] || continue + if [[ "$commit" == "$excluded"* ]]; then + return 0 + fi + done + return 1 +} + +is_release_note() { + local subject_lower + local version_bump_pattern='^(bump([[:space:]].*)?version|version[[:space:]]+bump)([[:space:]].*)?$' + local cleanup_pattern='^cleanup([[:space:][:punct:]].*)?$' + local conventional_noise_pattern='^(build|chore|ci|docs|style|test)(\([^)]*\))?:' + subject_lower="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + + [[ "$subject_lower" != *"[skip release notes]"* ]] || return 1 + [[ ! "$subject_lower" =~ $version_bump_pattern ]] || return 1 + [[ ! "$subject_lower" =~ $cleanup_pattern ]] || return 1 + [[ ! "$subject_lower" =~ $conventional_noise_pattern ]] || return 1 + return 0 +} + +resolve_username() { + local commit="$1" + local author_name="$2" + local author_email="$3" + local username="" + + if [[ "$author_email" =~ ^[0-9]+\+([^@]+)@users\.noreply\.github\.com$ ]]; then + username="${BASH_REMATCH[1]}" + elif [[ "$author_email" =~ ^([^@]+)@users\.noreply\.github\.com$ ]]; then + username="${BASH_REMATCH[1]}" + elif [[ "$offline" == false && -n "$repository" && -n "${GH_TOKEN:-}" ]] && command -v gh >/dev/null 2>&1; then + username="$(gh api "repos/${repository}/commits/${commit}" --jq '.author.login // empty' 2>/dev/null || true)" + if [[ -z "$username" ]]; then + username="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "repos/${repository}/commits/${commit}/pulls" \ + --jq '.[0].user.login // empty' \ + 2>/dev/null \ + || true + )" + fi + fi + + if [[ -z "$username" ]]; then + username="$(printf '%s' "$author_name" | tr -cd '[:alnum:]_-')" + fi + printf '%s' "${username:-unknown}" +} + +seen_subjects=$'\n' +separator=$'\x1f' + +while IFS="$separator" read -r commit short_hash subject author_name author_email; do + [[ -n "$commit" ]] || continue + is_excluded_hash "$commit" && continue + is_release_note "$subject" || continue + + normalized_subject="$(printf '%s' "$subject" | tr '[:upper:]' '[:lower:]' | sed -E 's/[[:space:]]+/ /g; s/[[:space:].]+$//')" + [[ "$seen_subjects" != *$'\n'"$normalized_subject"$'\n'* ]] || continue + seen_subjects+="${normalized_subject}"$'\n' + + display_subject="$(printf '%s' "$subject" | sed -E 's/[[:space:]]+$//; s/\.$//')" + username="$(resolve_username "$commit" "$author_name" "$author_email")" + printf '%s %s @%s \n' "$short_hash" "$display_subject" "$username" +done < <( + git log "${from_ref}..${to_ref}" --no-merges \ + --format="%H${separator}%h${separator}%s${separator}%an${separator}%ae" +) diff --git a/scripts/release-metadata.sh b/scripts/release-metadata.sh new file mode 100755 index 000000000..e518b3a3f --- /dev/null +++ b/scripts/release-metadata.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +set -euo pipefail + +version_file="${VERSION_FILE:-iosApp/Configuration/Version.xcconfig}" +target_ref="${1:-HEAD}" + +if ! git cat-file -e "${target_ref}^{commit}" 2>/dev/null; then + echo "Unknown release target: ${target_ref}" >&2 + exit 1 +fi + +read_version() { + local commit="$1" + git show "${commit}:${version_file}" \ + | sed -nE 's/^[[:space:]]*MARKETING_VERSION[[:space:]]*=[[:space:]]*([^[:space:]#]+).*$/\1/p' \ + | head -n 1 +} + +current_version="" +current_bump="" +previous_version="" +previous_bump="" + +while IFS= read -r commit; do + version="$(read_version "$commit")" + [[ -n "$version" ]] || continue + + if [[ -z "$current_version" ]]; then + current_version="$version" + current_bump="$commit" + elif [[ "$version" != "$current_version" ]]; then + previous_version="$version" + previous_bump="$commit" + break + fi +done < <(git log "$target_ref" --format='%H' -- "$version_file") + +if [[ -z "$current_bump" || -z "$previous_bump" ]]; then + echo "Could not find two distinct version bumps in ${version_file}." >&2 + exit 1 +fi + +if [[ ! "$current_version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then + echo "Invalid release version: ${current_version}" >&2 + exit 1 +fi + +printf 'version=%s\n' "$current_version" +printf 'tag=%s\n' "$current_version" +printf 'release_commit=%s\n' "$(git rev-parse "${target_ref}^{commit}")" +printf 'current_bump=%s\n' "$current_bump" +printf 'previous_version=%s\n' "$previous_version" +printf 'previous_bump=%s\n' "$previous_bump"