mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 08:09:21 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into fix-ios-popup-trailer
This commit is contained in:
commit
efe032d87f
230 changed files with 3405 additions and 19555 deletions
6
.github/workflows/triage-needs-info.yml
vendored
6
.github/workflows/triage-needs-info.yml
vendored
|
|
@ -32,8 +32,12 @@ jobs:
|
|||
return labels.includes(name.toLowerCase());
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractSection(title) {
|
||||
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
|
||||
const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m");
|
||||
const match = body.match(re);
|
||||
if (!match) return "";
|
||||
const start = match.index + match[0].length;
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
VERSION_NAME=0.1.1
|
||||
VERSION_CODE=2
|
||||
|
|
@ -5,13 +5,9 @@ import org.gradle.api.file.RegularFileProperty
|
|||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.Sync
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.jvm.tasks.Jar
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
|
||||
import java.util.Properties
|
||||
|
|
@ -30,12 +26,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
@get:Input
|
||||
abstract val appVersionCode: Property<Int>
|
||||
|
||||
@get:Input
|
||||
abstract val desktopAppVersionName: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val desktopAppVersionCode: Property<Int>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val props = Properties()
|
||||
|
|
@ -122,8 +112,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
|object AppVersionConfig {
|
||||
| const val VERSION_NAME = "${appVersionName.get()}"
|
||||
| const val VERSION_CODE = ${appVersionCode.get()}
|
||||
| const val DESKTOP_VERSION_NAME = "${desktopAppVersionName.get()}"
|
||||
| const val DESKTOP_VERSION_CODE = ${desktopAppVersionCode.get()}
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
|
|
@ -160,23 +148,6 @@ fun readXcconfigValue(file: File, key: String): String? {
|
|||
?.second
|
||||
}
|
||||
|
||||
fun shellQuote(value: String): String = "'${value.replace("'", "'\"'\"'")}'"
|
||||
|
||||
fun cmdQuote(value: String): String = "\"${value.replace("\"", "\"\"")}\""
|
||||
|
||||
fun psSingleQuote(value: String): String = "'${value.replace("'", "''")}'"
|
||||
|
||||
fun semanticVersionSortKey(value: String): String =
|
||||
value.split('.', '-', '_')
|
||||
.joinToString(".") { part ->
|
||||
part.toIntOrNull()?.toString()?.padStart(8, '0') ?: part
|
||||
}
|
||||
|
||||
fun newestDirectory(root: File): File? =
|
||||
root.takeIf(File::exists)
|
||||
?.listFiles(File::isDirectory)
|
||||
?.maxByOrNull { semanticVersionSortKey(it.name) }
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidApplication)
|
||||
|
|
@ -200,31 +171,6 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V
|
|||
val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION")
|
||||
?.toIntOrNull()
|
||||
?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}")
|
||||
val desktopVersionConfigFile = rootProject.file("composeApp/Configuration/DesktopVersion.properties")
|
||||
val desktopVersionProps = Properties().apply {
|
||||
if (desktopVersionConfigFile.exists()) {
|
||||
desktopVersionConfigFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
val desktopReleaseVersionName = (
|
||||
providers.gradleProperty("nuvio.desktop.versionName").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_VERSION_NAME")
|
||||
?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_NAME")
|
||||
?: desktopVersionProps.getProperty("VERSION_NAME")
|
||||
?: "0.1.0"
|
||||
).trim()
|
||||
require(desktopReleaseVersionName.isNotBlank()) {
|
||||
"Desktop version name must not be blank."
|
||||
}
|
||||
val desktopReleaseVersionCode = (
|
||||
providers.gradleProperty("nuvio.desktop.versionCode").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_VERSION_CODE")
|
||||
?: supabaseProps.getProperty("NUVIO_DESKTOP_VERSION_CODE")
|
||||
?: desktopVersionProps.getProperty("VERSION_CODE")
|
||||
)?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.toIntOrNull()
|
||||
?: 1
|
||||
val iosDistribution = (
|
||||
providers.gradleProperty("nuvio.ios.distribution").orNull
|
||||
?: System.getenv("NUVIO_IOS_DISTRIBUTION")
|
||||
|
|
@ -241,7 +187,6 @@ val iosDistributionSourceDir = if (iosDistribution == "full") {
|
|||
}
|
||||
val iosFrameworkBundleId = "com.nuvio.media"
|
||||
val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin")
|
||||
val fullPluginSourceDir = fullCommonSourceDir.resolve("com/nuvio/app/features/plugins")
|
||||
val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin")
|
||||
val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName ->
|
||||
taskName.substringAfterLast(':').lowercase()
|
||||
|
|
@ -260,356 +205,6 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
|
|||
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
|
||||
appVersionName.set(releaseAppVersionName)
|
||||
appVersionCode.set(releaseAppVersionCode)
|
||||
desktopAppVersionName.set(desktopReleaseVersionName)
|
||||
desktopAppVersionCode.set(desktopReleaseVersionCode)
|
||||
}
|
||||
|
||||
val isMacHost = System.getProperty("os.name").contains("mac", ignoreCase = true)
|
||||
val isWindowsHost = System.getProperty("os.name").contains("win", ignoreCase = true)
|
||||
val mpvKitDir = providers.gradleProperty("nuvio.mpvkit.dir")
|
||||
.orElse(rootProject.layout.projectDirectory.dir("MPVKit").asFile.absolutePath)
|
||||
val macosPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/macos/player_bridge.mm")
|
||||
val macosPlayerBridgeOutput = layout.buildDirectory.file("native/macos/libplayer_bridge.dylib")
|
||||
val macosPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
else -> "x86_64"
|
||||
}
|
||||
val mpvKitRoot = File(mpvKitDir.get())
|
||||
val mpvKitDistRoot = File(mpvKitRoot, "dist")
|
||||
val mpvKitLibmpvRoot = File(mpvKitDistRoot, "libmpv/macos/thin/$macosPlayerBridgeArch")
|
||||
val mpvKitLibmpvPkgConfigFile = File(mpvKitLibmpvRoot, "lib/pkgconfig/mpv.pc")
|
||||
val mpvKitGeneratedPkgConfigDirs = if (mpvKitDistRoot.exists()) {
|
||||
mpvKitDistRoot.walkTopDown()
|
||||
.filter { it.isDirectory && it.invariantSeparatorsPath.endsWith("/macos/thin/$macosPlayerBridgeArch/lib/pkgconfig") }
|
||||
.toList()
|
||||
.sortedBy { it.absolutePath }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val mpvKitGeneratedLibSearchArgs = mpvKitGeneratedPkgConfigDirs
|
||||
.mapNotNull { it.parentFile }
|
||||
.distinctBy { it.absolutePath }
|
||||
.joinToString(" ") { "-L${shellQuote(it.absolutePath)}" }
|
||||
val missingMpvKitMacosFrameworks = if (mpvKitLibmpvPkgConfigFile.exists()) emptyList() else listOf("mpv.pc")
|
||||
val missingMpvKitMacosMessage = """
|
||||
MPVKit macOS libmpv artifacts are missing for $macosPlayerBridgeArch: ${missingMpvKitMacosFrameworks.joinToString()}.
|
||||
Build MPVKit's macOS runtime first:
|
||||
cd ${mpvKitRoot.absolutePath}
|
||||
make build platform=macos
|
||||
Or pass -Pnuvio.mpvkit.dir=/absolute/path/to/MPVKit.
|
||||
""".trimIndent()
|
||||
val missingMpvKitMacosShellMessage = missingMpvKitMacosMessage.replace("'", "'\"'\"'")
|
||||
val macosPlayerBridgeSourceFile = macosPlayerBridgeSource.asFile
|
||||
val macosPlayerBridgeOutputFile = macosPlayerBridgeOutput.get().asFile
|
||||
val macosPlayerBridgeJavaHome = providers.systemProperty("java.home").get()
|
||||
val mpvKitLibmpvStaticLib = File(mpvKitLibmpvRoot, "lib/libmpv.a")
|
||||
if (isMacHost) {
|
||||
macosPlayerBridgeOutputFile.parentFile.mkdirs()
|
||||
}
|
||||
val macosPlayerBridgeCommand = if (missingMpvKitMacosFrameworks.isNotEmpty()) {
|
||||
listOf(
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"printf '%s\\n' '$missingMpvKitMacosShellMessage' >&2; exit 1",
|
||||
)
|
||||
} else {
|
||||
mutableListOf(
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"""
|
||||
set -eu
|
||||
SDKROOT="${'$'}(xcrun --sdk macosx --show-sdk-path)"
|
||||
SWIFTC="${'$'}(xcrun --toolchain XcodeDefault --find swiftc)"
|
||||
SWIFT_TOOLCHAIN="${'$'}{SWIFTC%/usr/bin/swiftc}"
|
||||
SWIFT_LIB="${'$'}{SWIFT_TOOLCHAIN}/usr/lib/swift/macosx"
|
||||
DEFAULT_PC="${'$'}(pkg-config --variable pc_path pkg-config)"
|
||||
export PKG_CONFIG_LIBDIR=${shellQuote(mpvKitGeneratedPkgConfigDirs.joinToString(":"))}:"${'$'}{DEFAULT_PC}"
|
||||
exec xcrun clang++ \
|
||||
-std=c++17 \
|
||||
-dynamiclib \
|
||||
-fobjc-arc \
|
||||
-ObjC++ \
|
||||
-arch ${shellQuote(macosPlayerBridgeArch)} \
|
||||
-isysroot "${'$'}{SDKROOT}" \
|
||||
-mmacosx-version-min=11.0 \
|
||||
${shellQuote(macosPlayerBridgeSourceFile.absolutePath)} \
|
||||
-o ${shellQuote(macosPlayerBridgeOutputFile.absolutePath)} \
|
||||
-I${shellQuote("$macosPlayerBridgeJavaHome/include")} \
|
||||
-I${shellQuote("$macosPlayerBridgeJavaHome/include/darwin")} \
|
||||
-I${shellQuote(File(mpvKitLibmpvRoot, "include").absolutePath)} \
|
||||
$mpvKitGeneratedLibSearchArgs \
|
||||
-L"${'$'}{SWIFT_LIB}" \
|
||||
-L/usr/lib/swift \
|
||||
-framework AppKit \
|
||||
-framework WebKit \
|
||||
-framework Metal \
|
||||
-framework Security \
|
||||
-lswiftCompatibility56 \
|
||||
-lswiftCompatibilityConcurrency \
|
||||
-lswiftCompatibilityPacks \
|
||||
-lc++ \
|
||||
${'$'}(pkg-config --libs --static mpv)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val buildMacosPlayerBridge = tasks.register<Exec>("buildMacosPlayerBridge") {
|
||||
notCompatibleWithConfigurationCache("Builds a host-local player bridge against MPVKit's macOS libmpv artifacts.")
|
||||
enabled = isMacHost
|
||||
inputs.file(macosPlayerBridgeSource)
|
||||
if (mpvKitLibmpvStaticLib.exists()) {
|
||||
inputs.file(mpvKitLibmpvStaticLib)
|
||||
}
|
||||
if (mpvKitLibmpvPkgConfigFile.exists()) {
|
||||
inputs.file(mpvKitLibmpvPkgConfigFile)
|
||||
}
|
||||
inputs.files(mpvKitGeneratedPkgConfigDirs.mapNotNull { it.parentFile?.resolve("lib")?.takeIf(File::exists) })
|
||||
outputs.file(macosPlayerBridgeOutput)
|
||||
commandLine(macosPlayerBridgeCommand)
|
||||
}
|
||||
|
||||
val windowsPlayerBridgeArch = when (System.getProperty("os.arch").lowercase()) {
|
||||
"aarch64", "arm64" -> "arm64"
|
||||
"x86" -> "x86"
|
||||
else -> "x64"
|
||||
}
|
||||
val windowsPlayerBridgeSource = layout.projectDirectory.file("src/desktopMain/native/windows/player_bridge.cpp")
|
||||
val windowsPlayerBridgeOutput = layout.buildDirectory.file("native/windows/player_bridge.dll")
|
||||
val windowsPlayerBridgeImportLib = layout.buildDirectory.file("native/windows/player_bridge.lib")
|
||||
val windowsPlayerBridgePdb = layout.buildDirectory.file("native/windows/player_bridge.pdb")
|
||||
val windowsPlayerBridgeObj = layout.buildDirectory.file("native/windows/player_bridge.obj")
|
||||
val windowsPlayerBridgeScript = layout.buildDirectory.file("native/windows/build-player-bridge.bat")
|
||||
val windowsPlayerRuntimeOutput = layout.buildDirectory.dir("native/windows-runtime")
|
||||
if (isWindowsHost) {
|
||||
windowsPlayerBridgeOutput.get().asFile.parentFile.mkdirs()
|
||||
}
|
||||
val windowsWebView2Root = providers.gradleProperty("nuvio.webview2.dir").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: newestDirectory(File(System.getProperty("user.home"), ".nuget/packages/microsoft.web.webview2"))
|
||||
?: File("__missing_webview2__")
|
||||
val windowsWebView2IncludeDir = File(windowsWebView2Root, "build/native/include")
|
||||
val windowsWebView2NativeDir = File(windowsWebView2Root, "build/native/$windowsPlayerBridgeArch")
|
||||
val windowsWebView2LoaderLib = File(windowsWebView2NativeDir, "WebView2Loader.dll.lib")
|
||||
val windowsWebView2LoaderDll = File(windowsWebView2NativeDir, "WebView2Loader.dll")
|
||||
val windowsLibmpvRuntimeDir = providers.gradleProperty("nuvio.windows.libmpv.runtimeDir").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: listOf(
|
||||
File("C:/Program Files (x86)/Nuvio/app/native"),
|
||||
File("C:/Program Files/Nuvio/app/native"),
|
||||
).firstOrNull { File(it, "libmpv-2.dll").exists() }
|
||||
val windowsLibmpvDll = providers.gradleProperty("nuvio.windows.libmpv.dll").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: windowsLibmpvRuntimeDir?.resolve("libmpv-2.dll")
|
||||
?: listOf(
|
||||
File("C:/msys64/ucrt64/bin/libmpv-2.dll"),
|
||||
File("C:/msys64/mingw64/bin/libmpv-2.dll"),
|
||||
).firstOrNull(File::exists)
|
||||
val windowsVsWhere = File("C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe")
|
||||
val windowsVcvarsRelativePath = when (windowsPlayerBridgeArch) {
|
||||
"x86" -> "VC\\Auxiliary\\Build\\vcvars32.bat"
|
||||
"arm64" -> "VC\\Auxiliary\\Build\\vcvarsarm64.bat"
|
||||
else -> "VC\\Auxiliary\\Build\\vcvars64.bat"
|
||||
}
|
||||
val windowsVcvarsPath = providers.gradleProperty("nuvio.windows.vcvars.path").orNull
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val windowsPlayerBridgeJavaHome = providers.systemProperty("java.home").get()
|
||||
val missingWindowsPlayerBridgeInputs = listOfNotNull(
|
||||
"WebView2.h".takeUnless { windowsWebView2IncludeDir.resolve("WebView2.h").exists() },
|
||||
"WebView2Loader.dll.lib".takeUnless { windowsWebView2LoaderLib.exists() },
|
||||
)
|
||||
val missingWindowsPlayerBridgeMessage = """
|
||||
Windows desktop player bridge inputs are missing: ${missingWindowsPlayerBridgeInputs.joinToString()}.
|
||||
Install the Microsoft.Web.WebView2 NuGet package or pass -Pnuvio.webview2.dir=C:/path/to/microsoft.web.webview2/version.
|
||||
libmpv is loaded at runtime; pass -Pnuvio.windows.libmpv.runtimeDir=C:/path/to/mpv-dlls to bundle it.
|
||||
""".trimIndent()
|
||||
val windowsPlayerBridgeCommand = if (missingWindowsPlayerBridgeInputs.isNotEmpty()) {
|
||||
listOf(
|
||||
"cmd",
|
||||
"/c",
|
||||
"echo ${missingWindowsPlayerBridgeMessage.replace("\n", " ")} 1>&2 && exit /b 1",
|
||||
)
|
||||
} else {
|
||||
val sourceFile = windowsPlayerBridgeSource.asFile
|
||||
val outputFile = windowsPlayerBridgeOutput.get().asFile
|
||||
val importLibFile = windowsPlayerBridgeImportLib.get().asFile
|
||||
val pdbFile = windowsPlayerBridgePdb.get().asFile
|
||||
val objFile = windowsPlayerBridgeObj.get().asFile
|
||||
val javaIncludeDir = File(windowsPlayerBridgeJavaHome, "include")
|
||||
val javaWin32IncludeDir = File(javaIncludeDir, "win32")
|
||||
val compileCommand = listOf(
|
||||
"cl",
|
||||
"/nologo",
|
||||
"/EHsc",
|
||||
"/std:c++17",
|
||||
"/LD",
|
||||
"/DUNICODE",
|
||||
"/D_UNICODE",
|
||||
"/DNOMINMAX",
|
||||
"/DWIN32_LEAN_AND_MEAN",
|
||||
"/permissive-",
|
||||
cmdQuote(sourceFile.absolutePath),
|
||||
"/I${cmdQuote(javaIncludeDir.absolutePath)}",
|
||||
"/I${cmdQuote(javaWin32IncludeDir.absolutePath)}",
|
||||
"/I${cmdQuote(windowsWebView2IncludeDir.absolutePath)}",
|
||||
"/Fo${cmdQuote(objFile.absolutePath)}",
|
||||
"/Fd${cmdQuote(pdbFile.absolutePath)}",
|
||||
"/Fe${cmdQuote(outputFile.absolutePath)}",
|
||||
"/link",
|
||||
"/NOLOGO",
|
||||
"/INCREMENTAL:NO",
|
||||
"/IMPLIB:${cmdQuote(importLibFile.absolutePath)}",
|
||||
cmdQuote(windowsWebView2LoaderLib.absolutePath),
|
||||
"Ole32.lib",
|
||||
"User32.lib",
|
||||
"Gdi32.lib",
|
||||
"Dwmapi.lib",
|
||||
).joinToString(" ")
|
||||
val powershellCompileCommand = compileCommand.replace("\"", "__DQ__")
|
||||
val powershellCommand = """
|
||||
${'$'}ErrorActionPreference = 'Stop'
|
||||
${'$'}dq = [char]34
|
||||
${'$'}vcvars = ${psSingleQuote(windowsVcvarsPath.orEmpty())}
|
||||
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars)) {
|
||||
${'$'}vswhere = ${psSingleQuote(windowsVsWhere.absolutePath)}
|
||||
if (Test-Path -LiteralPath ${'$'}vswhere) {
|
||||
${'$'}vcvars = & ${'$'}vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find ${psSingleQuote(windowsVcvarsRelativePath)} | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace(${'$'}vcvars) -or -not (Test-Path -LiteralPath ${'$'}vcvars)) {
|
||||
Write-Error 'Visual Studio C++ toolchain was not found. Install MSVC or pass -Pnuvio.windows.vcvars.path=C:\path\to\vcvars64.bat.'
|
||||
exit 1
|
||||
}
|
||||
${'$'}vcvars = ([string]${'$'}vcvars).Trim()
|
||||
${'$'}bat = ${psSingleQuote(windowsPlayerBridgeScript.get().asFile.absolutePath)}
|
||||
${'$'}compile = ${psSingleQuote(powershellCompileCommand)}.Replace('__DQ__', ${'$'}dq)
|
||||
${'$'}lines = @(
|
||||
'@echo off',
|
||||
('set {0}VCVARS={1}{0}' -f ${'$'}dq, ${'$'}vcvars),
|
||||
('call {0}%VCVARS%{0} >nul' -f ${'$'}dq),
|
||||
'if errorlevel 1 exit /b %errorlevel%',
|
||||
${'$'}compile,
|
||||
'exit /b %ERRORLEVEL%'
|
||||
)
|
||||
Set-Content -LiteralPath ${'$'}bat -Value ${'$'}lines -Encoding ASCII
|
||||
& cmd.exe /d /c ${'$'}bat
|
||||
${'$'}code = ${'$'}LASTEXITCODE
|
||||
if (${'$'}code -ne 0) { exit ${'$'}code }
|
||||
""".trimIndent()
|
||||
listOf(
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
powershellCommand,
|
||||
)
|
||||
}
|
||||
val buildWindowsPlayerBridge = tasks.register<Exec>("buildWindowsPlayerBridge") {
|
||||
notCompatibleWithConfigurationCache("Builds a host-local player bridge against WebView2 and libmpv for Windows.")
|
||||
enabled = isWindowsHost
|
||||
inputs.file(windowsPlayerBridgeSource)
|
||||
if (windowsWebView2IncludeDir.exists()) {
|
||||
inputs.dir(windowsWebView2IncludeDir)
|
||||
}
|
||||
if (windowsWebView2LoaderLib.exists()) {
|
||||
inputs.file(windowsWebView2LoaderLib)
|
||||
}
|
||||
outputs.file(windowsPlayerBridgeOutput)
|
||||
outputs.file(windowsPlayerBridgeImportLib)
|
||||
outputs.file(windowsPlayerBridgePdb)
|
||||
commandLine(windowsPlayerBridgeCommand)
|
||||
}
|
||||
|
||||
val prepareWindowsPlayerRuntime = tasks.register<Sync>("prepareWindowsPlayerRuntime") {
|
||||
enabled = isWindowsHost
|
||||
into(windowsPlayerRuntimeOutput)
|
||||
if (windowsWebView2LoaderDll.exists()) {
|
||||
from(windowsWebView2LoaderDll)
|
||||
}
|
||||
when {
|
||||
windowsLibmpvRuntimeDir?.exists() == true -> {
|
||||
from(windowsLibmpvRuntimeDir) {
|
||||
include("*.dll")
|
||||
}
|
||||
}
|
||||
windowsLibmpvDll?.exists() == true -> {
|
||||
from(windowsLibmpvDll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val generateWindowsPlayerRuntimeIndex = tasks.register<GenerateNativeRuntimeIndexTask>("generateWindowsPlayerRuntimeIndex") {
|
||||
enabled = isWindowsHost
|
||||
dependsOn(prepareWindowsPlayerRuntime)
|
||||
runtimeDir.set(windowsPlayerRuntimeOutput)
|
||||
indexFile.set(windowsPlayerRuntimeOutput.map { it.file("runtime-files.txt") })
|
||||
}
|
||||
|
||||
abstract class GenerateNativeRuntimeIndexTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
abstract val runtimeDir: DirectoryProperty
|
||||
|
||||
@get:OutputFile
|
||||
abstract val indexFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val dir = runtimeDir.get().asFile
|
||||
val files = dir
|
||||
.listFiles { file -> file.isFile && file.name != indexFile.get().asFile.name }
|
||||
.orEmpty()
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
indexFile.get().asFile.writeText(files.joinToString(separator = "\n", postfix = "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Jar>().configureEach {
|
||||
if (isMacHost && name == "desktopJar") {
|
||||
dependsOn(buildMacosPlayerBridge)
|
||||
from(macosPlayerBridgeOutput) {
|
||||
into("native/macos")
|
||||
}
|
||||
}
|
||||
if (isWindowsHost && name == "desktopJar") {
|
||||
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
|
||||
from(windowsPlayerBridgeOutput) {
|
||||
into("native/windows")
|
||||
}
|
||||
from(windowsPlayerRuntimeOutput) {
|
||||
into("native/windows")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isWindowsHost) {
|
||||
val desktopNativePlayerTasks = setOf(
|
||||
"run",
|
||||
"runRelease",
|
||||
"desktopRun",
|
||||
"runDistributable",
|
||||
"runReleaseDistributable",
|
||||
"desktopRunHot",
|
||||
"hotRunDesktop",
|
||||
"hotRunDesktopAsync",
|
||||
"hotDevDesktop",
|
||||
"hotDevDesktopAsync",
|
||||
"createDistributable",
|
||||
"createReleaseDistributable",
|
||||
"createRuntimeImage",
|
||||
"package",
|
||||
"packageDistributionForCurrentOS",
|
||||
"packageMsi",
|
||||
"packageUberJarForCurrentOS",
|
||||
"packageReleaseDistributionForCurrentOS",
|
||||
"packageReleaseMsi",
|
||||
"packageReleaseUberJarForCurrentOS",
|
||||
)
|
||||
tasks.matching { it.name in desktopNativePlayerTasks }.configureEach {
|
||||
dependsOn(buildWindowsPlayerBridge, prepareWindowsPlayerRuntime, generateWindowsPlayerRuntimeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||
|
|
@ -622,12 +217,6 @@ kotlin {
|
|||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
jvm("desktop") {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
|
||||
val iosTargets = listOf(
|
||||
iosArm64(),
|
||||
|
|
@ -692,16 +281,6 @@ kotlin {
|
|||
implementation(libs.androidx.media3.extractor)
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("lib-*.aar"))))
|
||||
}
|
||||
val desktopMain by getting {
|
||||
kotlin.srcDir(fullPluginSourceDir)
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutines.swing)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.quickjs.kt)
|
||||
implementation(libs.ksoup)
|
||||
}
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
|
|
@ -730,46 +309,6 @@ kotlin {
|
|||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.nuvio.app.MainKt"
|
||||
val smokePlayerUrl = providers.gradleProperty("nuvio.desktop.smokePlayerUrl").orNull
|
||||
?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL")
|
||||
jvmArgs += listOfNotNull(
|
||||
"-Dapple.awt.application.appearance=NSAppearanceNameDarkAqua",
|
||||
"--add-opens=java.desktop/java.awt=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.lwawt=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.lwawt.macosx=ALL-UNNAMED",
|
||||
"--add-opens=java.desktop/sun.awt.windows=ALL-UNNAMED",
|
||||
smokePlayerUrl?.takeIf { it.isNotBlank() }?.let { "-Dnuvio.desktop.smokePlayerUrl=$it" },
|
||||
)
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "Nuvio"
|
||||
packageVersion = desktopReleaseVersionName
|
||||
vendor = "Nuvio Media"
|
||||
modules("java.net.http")
|
||||
macOS {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.icns"))
|
||||
}
|
||||
windows {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.ico"))
|
||||
shortcut = true
|
||||
menu = true
|
||||
menuGroup = "Nuvio"
|
||||
}
|
||||
linux {
|
||||
iconFile.set(project.file("src/desktopMain/resources/icons/nuvio-app-icon.png"))
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes.release.proguard {
|
||||
isEnabled.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
dependencies {
|
||||
add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar"))
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,8 +2,6 @@ package com.nuvio.app.core.build
|
|||
|
||||
actual object AppFeaturePolicy {
|
||||
actual val pluginsEnabled: Boolean = true
|
||||
actual val downloadsEnabled: Boolean = true
|
||||
actual val notificationsEnabled: Boolean = true
|
||||
actual val p2pEnabled: Boolean = true
|
||||
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
|
||||
actual val heroTrailerPlaybackSupported: Boolean = true
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
actual object AppVersionPolicy {
|
||||
actual val displayVersionName: String = AppVersionConfig.VERSION_NAME
|
||||
actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE
|
||||
actual val basedOnVersionName: String? = null
|
||||
}
|
||||
|
|
@ -26,6 +26,4 @@ internal object PluginStorage {
|
|||
|
||||
internal fun currentPluginPlatform(): String = "android"
|
||||
|
||||
internal fun currentPluginPlatformTags(): Set<String> = setOf(currentPluginPlatform())
|
||||
|
||||
internal fun currentEpochMillis(): Long = System.currentTimeMillis()
|
||||
|
|
|
|||
|
|
@ -8,5 +8,4 @@ class AndroidPlatform : Platform {
|
|||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||
|
||||
internal actual val isIos: Boolean = false
|
||||
internal actual val isDesktop: Boolean = false
|
||||
internal actual val isIos: Boolean = false
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
||||
@Composable
|
||||
internal actual fun NuvioAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
placeholder: Painter?,
|
||||
error: Painter?,
|
||||
fallback: Painter?,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)?,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)?,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)?,
|
||||
alignment: Alignment,
|
||||
contentScale: ContentScale,
|
||||
alpha: Float,
|
||||
colorFilter: ColorFilter?,
|
||||
filterQuality: FilterQuality?,
|
||||
clipToBounds: Boolean,
|
||||
desktopImageScaling: NuvioDesktopImageScaling,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
error = error,
|
||||
fallback = fallback,
|
||||
onLoading = onLoading,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality ?: DefaultFilterQuality,
|
||||
clipToBounds = clipToBounds,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier = this
|
||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.p2p
|
|||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.nuvio.app.core.i18n.localizedP2pUnknownTorrentError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -93,7 +94,7 @@ actual object P2pStreamingEngine {
|
|||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (isCurrentGeneration(generation)) {
|
||||
_state.value = P2pStreamingState.Error(e.message ?: "Unknown torrent error")
|
||||
_state.value = P2pStreamingState.Error(e.message ?: localizedP2pUnknownTorrentError())
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@ package com.nuvio.app.features.player
|
|||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
|
||||
internal fun playbackMediaItemFromUrl(
|
||||
url: String,
|
||||
responseHeaders: Map<String, String> = emptyMap(),
|
||||
streamType: String? = null,
|
||||
): MediaItem {
|
||||
val builder = MediaItem.Builder().setUri(url)
|
||||
inferPlaybackMimeType(
|
||||
url = url,
|
||||
responseHeaders = responseHeaders,
|
||||
streamType = streamType,
|
||||
)?.let(builder::setMimeType)
|
||||
return builder.build()
|
||||
}
|
||||
|
|
@ -19,10 +23,26 @@ internal fun playbackMediaItemFromUrl(
|
|||
private fun inferPlaybackMimeType(
|
||||
url: String,
|
||||
responseHeaders: Map<String, String>,
|
||||
streamType: String?,
|
||||
): String? =
|
||||
inferMimeTypeFromResponseHeaders(responseHeaders)
|
||||
inferMimeTypeFromStreamType(streamType)
|
||||
?: inferMimeTypeFromResponseHeaders(responseHeaders)
|
||||
?: inferMimeTypeFromPath(url)
|
||||
|
||||
private fun inferMimeTypeFromStreamType(streamType: String?): String? {
|
||||
val normalized = streamType
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
return when (normalized) {
|
||||
"hls", "m3u8" -> MimeTypes.APPLICATION_M3U8
|
||||
"dash", "mpd" -> MimeTypes.APPLICATION_MPD
|
||||
"smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): String? {
|
||||
if (headers.isEmpty()) return null
|
||||
|
||||
|
|
@ -50,7 +70,7 @@ private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): Stri
|
|||
return inferMimeTypeFromPath(filename)
|
||||
}
|
||||
|
||||
private fun normalizeMimeType(contentType: String?): String? {
|
||||
internal fun normalizeMimeType(contentType: String?): String? {
|
||||
val normalized = contentType
|
||||
?.substringBefore(';')
|
||||
?.trim()
|
||||
|
|
@ -169,12 +189,44 @@ private fun inferMimeTypeFromQuery(query: String): String? {
|
|||
private fun inferMimeTypeFromDelimitedToken(value: String): String? =
|
||||
when {
|
||||
DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
|
||||
DELIMITED_HLS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
|
||||
DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD
|
||||
DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun probeMimeType(url: String, headers: Map<String, String>): String? {
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
|
||||
val methods = listOf("HEAD", "GET")
|
||||
methods.forEach { method ->
|
||||
runCatching {
|
||||
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 3_000
|
||||
readTimeout = 3_000
|
||||
instanceFollowRedirects = true
|
||||
setRequestProperty("User-Agent", "Mozilla/5.0")
|
||||
setRequestProperty("Accept", "*/*")
|
||||
headers.forEach { (key, value) ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode in 200..299) {
|
||||
val contentType = connection.contentType
|
||||
normalizeMimeType(contentType)
|
||||
} else null
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}.getOrNull()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private const val MIME_VIDEO_QUICK_TIME = "video/quicktime"
|
||||
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&-])m3u8($|[=/_.?&-])")
|
||||
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&-])mpd($|[=/_.?&-])")
|
||||
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&-])(ism|isml)($|[=/_.?&-])")
|
||||
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&%-])m3u8($|[=/_.?&%-])")
|
||||
private val DELIMITED_HLS_PATTERN = Regex("(^|[=/_.?&%-])hls($|[=/_.?&%-])")
|
||||
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&%-])mpd($|[=/_.?&%-])")
|
||||
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&%-])(ism|isml)($|[=/_.?&%-])")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.media3.ui.PlayerView
|
|||
import androidx.media3.ui.SubtitleView
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import com.nuvio.app.R
|
||||
import com.nuvio.app.features.streams.normalizeStreamType
|
||||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -75,17 +76,12 @@ actual fun PlatformPlayerSurface(
|
|||
sourceAudioUrl: String?,
|
||||
sourceHeaders: Map<String, String>,
|
||||
sourceResponseHeaders: Map<String, String>,
|
||||
streamType: String?,
|
||||
useYoutubeChunkedPlayback: Boolean,
|
||||
modifier: Modifier,
|
||||
playWhenReady: Boolean,
|
||||
resizeMode: PlayerResizeMode,
|
||||
initialPositionMs: Long,
|
||||
useNativeController: Boolean,
|
||||
playerControlsState: PlayerControlsState,
|
||||
onPlayerControlsAction: (PlayerControlsAction) -> Boolean,
|
||||
onPlayerControlsEvent: (String, Double) -> Boolean,
|
||||
onPlayerControlsScrubChange: (Long) -> Boolean,
|
||||
onPlayerControlsScrubFinished: (Long) -> Boolean,
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
@ -107,6 +103,9 @@ actual fun PlatformPlayerSurface(
|
|||
val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) {
|
||||
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
|
||||
}
|
||||
val normalizedStreamType = remember(streamType) {
|
||||
normalizeStreamType(streamType)
|
||||
}
|
||||
val useLibass = playerSettings.useLibass
|
||||
val libassRenderType = runCatching {
|
||||
LibassRenderType.valueOf(playerSettings.libassRenderType)
|
||||
|
|
@ -116,6 +115,7 @@ actual fun PlatformPlayerSurface(
|
|||
sourceAudioUrl.orEmpty(),
|
||||
sanitizedSourceHeaders,
|
||||
sanitizedSourceResponseHeaders,
|
||||
normalizedStreamType.orEmpty(),
|
||||
useYoutubeChunkedPlayback,
|
||||
)
|
||||
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
|
||||
|
|
@ -126,6 +126,17 @@ actual fun PlatformPlayerSurface(
|
|||
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
|
||||
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
|
||||
|
||||
val initialMediaItem = remember(playerSourceKey) {
|
||||
playbackMediaItemFromUrl(
|
||||
url = sourceUrl,
|
||||
responseHeaders = sanitizedSourceResponseHeaders,
|
||||
streamType = normalizedStreamType,
|
||||
)
|
||||
}
|
||||
|
||||
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
|
||||
var probeAttempted by remember(playerSourceKey) { mutableStateOf(false) }
|
||||
|
||||
val extractorsFactory = remember {
|
||||
DefaultExtractorsFactory()
|
||||
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
|
||||
|
|
@ -168,6 +179,7 @@ actual fun PlatformPlayerSurface(
|
|||
sourceAudioUrl,
|
||||
sanitizedSourceHeaders,
|
||||
sanitizedSourceResponseHeaders,
|
||||
normalizedStreamType,
|
||||
useYoutubeChunkedPlayback,
|
||||
effectiveDecoderPriority,
|
||||
) {
|
||||
|
|
@ -227,17 +239,13 @@ actual fun PlatformPlayerSurface(
|
|||
.build()
|
||||
}
|
||||
|
||||
player.apply {
|
||||
setPlaybackMediaItem(
|
||||
videoMediaItem = playbackMediaItemFromUrl(
|
||||
url = sourceUrl,
|
||||
responseHeaders = sanitizedSourceResponseHeaders,
|
||||
),
|
||||
startPositionMs = fallbackStartPositionMs,
|
||||
)
|
||||
prepare()
|
||||
this.playWhenReady = playWhenReady
|
||||
}
|
||||
player
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer, resolvedMediaItem) {
|
||||
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
|
||||
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
|
||||
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
|
||||
|
|
@ -262,25 +270,54 @@ actual fun PlatformPlayerSurface(
|
|||
exoPlayer.pause()
|
||||
}
|
||||
|
||||
fun reportPlayerError(error: PlaybackException) {
|
||||
if (
|
||||
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
|
||||
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
|
||||
error.isDecoderFailure()
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
|
||||
error,
|
||||
)
|
||||
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
latestOnError.value(null)
|
||||
return
|
||||
}
|
||||
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
|
||||
}
|
||||
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
syncPlayerViewKeepScreenOn()
|
||||
if (
|
||||
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
|
||||
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
|
||||
error.isDecoderFailure()
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
|
||||
error,
|
||||
)
|
||||
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
latestOnError.value(null)
|
||||
|
||||
val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ||
|
||||
error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED ||
|
||||
error.cause?.toString()?.contains("UnrecognizedInputFormatException") == true
|
||||
|
||||
if (isSourceError && !probeAttempted) {
|
||||
probeAttempted = true
|
||||
coroutineScope.launch {
|
||||
val probedMime = withContext(Dispatchers.IO) {
|
||||
probeMimeType(sourceUrl, sanitizedSourceHeaders)
|
||||
}
|
||||
if (probedMime != null) {
|
||||
Log.d(TAG, "Playback failed with source error. Probed MIME type: $probedMime. Retrying...")
|
||||
resolvedMediaItem = MediaItem.Builder()
|
||||
.setUri(sourceUrl)
|
||||
.setMimeType(probedMime)
|
||||
.build()
|
||||
latestOnError.value(null)
|
||||
return@launch
|
||||
}
|
||||
reportPlayerError(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
|
||||
|
||||
reportPlayerError(error)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ actual object PlayerSettingsStorage {
|
|||
private const val resizeModeKey = "resize_mode"
|
||||
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
|
||||
private const val holdToSpeedValueKey = "hold_to_speed_value"
|
||||
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
|
||||
private const val externalPlayerEnabledKey = "external_player_enabled"
|
||||
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
|
||||
private const val externalPlayerIdKey = "external_player_id"
|
||||
|
|
@ -85,6 +86,7 @@ actual object PlayerSettingsStorage {
|
|||
resizeModeKey,
|
||||
holdToSpeedEnabledKey,
|
||||
holdToSpeedValueKey,
|
||||
touchGesturesEnabledKey,
|
||||
externalPlayerEnabledKey,
|
||||
externalPlayerForwardSubtitlesKey,
|
||||
externalPlayerIdKey,
|
||||
|
|
@ -209,6 +211,23 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadTouchGesturesEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(touchGesturesEnabledKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveTouchGesturesEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(touchGesturesEnabledKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadExternalPlayerEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(externalPlayerEnabledKey)
|
||||
|
|
@ -958,6 +977,7 @@ actual object PlayerSettingsStorage {
|
|||
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
|
||||
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
|
||||
loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
|
||||
|
|
@ -1024,6 +1044,7 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
|
||||
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
|
||||
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
|
||||
payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled)
|
||||
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
|
||||
payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles)
|
||||
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
|
||||
|
|
|
|||
|
|
@ -18,13 +18,11 @@ actual object ThemeSettingsStorage {
|
|||
private const val selectedThemeKey = "selected_theme"
|
||||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val desktopNavigationLayoutKey = "desktop_navigation_layout"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
desktopNavigationLayoutKey,
|
||||
)
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
|
@ -70,16 +68,6 @@ actual object ThemeSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadDesktopNavigationLayout(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(desktopNavigationLayoutKey), null)
|
||||
|
||||
actual fun saveDesktopNavigationLayout(layoutName: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(desktopNavigationLayoutKey), layoutName)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSelectedAppLanguage(): String? {
|
||||
val value = preferences?.getString(selectedAppLanguageKey, null)
|
||||
if (value != null) return value
|
||||
|
|
@ -105,7 +93,6 @@ actual object ThemeSettingsStorage {
|
|||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadDesktopNavigationLayout()?.let { put(desktopNavigationLayoutKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||
|
|
@ -116,7 +103,6 @@ actual object ThemeSettingsStorage {
|
|||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(desktopNavigationLayoutKey)?.let(::saveDesktopNavigationLayout)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package com.nuvio.app.core.build
|
|||
|
||||
actual object AppFeaturePolicy {
|
||||
actual val pluginsEnabled: Boolean = false
|
||||
actual val downloadsEnabled: Boolean = true
|
||||
actual val notificationsEnabled: Boolean = true
|
||||
actual val p2pEnabled: Boolean = true
|
||||
actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
|
||||
actual val heroTrailerPlaybackSupported: Boolean = false
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
actual object AppVersionPolicy {
|
||||
actual val displayVersionName: String = AppVersionConfig.VERSION_NAME
|
||||
actual val displayVersionCode: Int = AppVersionConfig.VERSION_CODE
|
||||
actual val basedOnVersionName: String? = null
|
||||
}
|
||||
|
|
@ -320,8 +320,11 @@
|
|||
<string name="compose_nav_search">Rechercher</string>
|
||||
<string name="compose_player_audio_tracks">Pistes audio</string>
|
||||
<string name="compose_player_audio">Audio</string>
|
||||
<string name="compose_player_auto_sync">Synchro auto</string>
|
||||
<string name="compose_player_bold">Gras</string>
|
||||
<string name="compose_player_built_in">Intégré</string>
|
||||
<string name="compose_player_bottom_offset">Décalage inférieur</string>
|
||||
<string name="compose_player_capture_line">Capturer</string>
|
||||
<string name="compose_player_close">Fermer le lecteur</string>
|
||||
<string name="compose_player_color">Couleur</string>
|
||||
<string name="compose_player_currently_playing">En cours de lecture</string>
|
||||
|
|
@ -332,11 +335,15 @@
|
|||
<string name="compose_player_font_size">Taille de police</string>
|
||||
<string name="compose_player_font_size_value">%1$dsp</string>
|
||||
<string name="compose_player_lock_controls">Verrouiller les contrôles du lecteur</string>
|
||||
<string name="compose_player_loading_lines">Chargement des lignes de sous-titres…</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">Aucune ligne de sous-titres trouvée</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Impossible de charger les lignes de sous-titres</string>
|
||||
<string name="compose_player_no_audio_tracks_available">Aucune piste audio disponible</string>
|
||||
<string name="compose_player_no_episodes_available">Aucun épisode disponible</string>
|
||||
<string name="compose_player_no_streams_found">Aucun stream trouvé</string>
|
||||
<string name="compose_player_none">Aucun</string>
|
||||
<string name="compose_player_outline">Contour</string>
|
||||
<string name="compose_player_outline_color">Couleur du contour</string>
|
||||
<string name="compose_player_panel_episodes">Épisodes</string>
|
||||
<string name="compose_player_panel_sources">Sources</string>
|
||||
<string name="compose_player_panel_streams">Streams</string>
|
||||
|
|
@ -344,6 +351,8 @@
|
|||
<string name="compose_player_playing">Lecture en cours</string>
|
||||
<string name="compose_player_fetch_subtitles">Appuie pour chercher des sous-titres</string>
|
||||
<string name="compose_player_go_back">Retour</string>
|
||||
<string name="compose_player_reload">Recharger</string>
|
||||
<string name="compose_player_reset">Réinitialiser</string>
|
||||
<string name="compose_player_reset_defaults">Rétablir les valeurs par défaut</string>
|
||||
<string name="compose_player_resize_fill">Remplir</string>
|
||||
<string name="compose_player_resize_fit">Ajuster</string>
|
||||
|
|
@ -356,8 +365,11 @@
|
|||
<string name="compose_player_seek_forward_10">Avancer de 10 secondes</string>
|
||||
<string name="compose_player_sources">Sources</string>
|
||||
<string name="compose_player_style">Style</string>
|
||||
<string name="compose_player_select_addon_subtitle_first">Sélectionne d’abord un sous-titre d’addon</string>
|
||||
<string name="compose_player_subs">Sous-titres</string>
|
||||
<string name="compose_player_subtitle_delay">Délai des sous-titres</string>
|
||||
<string name="compose_player_subtitles">Sous-titres</string>
|
||||
<string name="compose_player_text_opacity">Opacité du texte</string>
|
||||
<string name="compose_player_brightness_level">Luminosité %1$s</string>
|
||||
<string name="compose_player_volume_level">Volume %1$s</string>
|
||||
<string name="compose_player_muted">Muet</string>
|
||||
|
|
@ -386,6 +398,7 @@
|
|||
<string name="compose_settings_category_general">Général</string>
|
||||
<string name="compose_settings_page_account">Compte</string>
|
||||
<string name="compose_settings_page_addons">Addons</string>
|
||||
<string name="compose_settings_page_advanced">Avancé</string>
|
||||
<string name="compose_settings_page_appearance">Apparence</string>
|
||||
<string name="compose_settings_page_content_discovery">Contenu et découverte</string>
|
||||
<string name="compose_settings_page_continue_watching">Continuer à regarder</string>
|
||||
|
|
@ -400,12 +413,15 @@
|
|||
<string name="compose_settings_page_plugins">Plugins</string>
|
||||
<string name="compose_settings_page_poster_customization">Personnalisation des affiches</string>
|
||||
<string name="compose_settings_page_root">Paramètres</string>
|
||||
<string name="compose_settings_page_streams">Streams</string>
|
||||
<string name="compose_settings_page_supporters_contributors">Supporters et contributeurs</string>
|
||||
<string name="compose_settings_page_tmdb_enrichment">Enrichissement TMDB</string>
|
||||
<string name="compose_settings_page_trakt">Trakt</string>
|
||||
<string name="compose_settings_root_about_section">À PROPOS</string>
|
||||
<string name="compose_settings_root_account_description">Gère ton compte, déconnecte-toi ou supprime-le.</string>
|
||||
<string name="compose_settings_root_account_section">COMPTE</string>
|
||||
<string name="compose_settings_root_advanced_description">Comportement au démarrage et des profils.</string>
|
||||
<string name="compose_settings_root_advanced_section">AVANCÉ</string>
|
||||
<string name="compose_settings_root_appearance_description">Ajuste la présentation de l’accueil et les préférences visuelles.</string>
|
||||
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l’application.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
|
||||
|
|
@ -415,12 +431,20 @@
|
|||
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Connecte les services TMDB et MDBList.</string>
|
||||
<string name="compose_settings_root_notifications_description">Gère les alertes de sortie d’épisodes et envoie une notification de test.</string>
|
||||
<string name="compose_settings_root_streams_description">Affichage des résultats de streams et règles d’URL de badges.</string>
|
||||
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
|
||||
<string name="compose_settings_root_switch_profile_title">Changer de profil</string>
|
||||
<string name="compose_settings_root_trakt_description">Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt.</string>
|
||||
<string name="settings_search_empty">Aucun paramètre trouvé.</string>
|
||||
<string name="settings_search_placeholder">Rechercher dans les paramètres…</string>
|
||||
<string name="settings_search_results_section">RÉSULTATS</string>
|
||||
<string name="settings_advanced_section_startup">DÉMARRAGE</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_remember_last_profile">Mémoriser le dernier profil</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Mémorise le dernier profil sélectionné au démarrage</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Vider le cache de Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache vidé</string>
|
||||
<string name="settings_licenses_attributions_section_app">LICENCE DE L’APP</string>
|
||||
<string name="settings_licenses_attributions_section_data">DONNÉES ET SERVICES</string>
|
||||
<string name="settings_licenses_attributions_section_playback">LICENCE DE LECTURE</string>
|
||||
|
|
@ -584,6 +608,8 @@
|
|||
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
|
||||
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l’écran d’accueil.</string>
|
||||
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
|
||||
<string name="settings_continue_watching_style_card">Carte</string>
|
||||
<string name="settings_continue_watching_style_card_description">Carte paysage façon TV</string>
|
||||
<string name="settings_continue_watching_style_poster">Affiche</string>
|
||||
<string name="settings_continue_watching_style_poster_description">Carte d’affiche centrée sur la couverture</string>
|
||||
<string name="settings_continue_watching_style_wide">Large</string>
|
||||
|
|
@ -646,6 +672,38 @@
|
|||
<string name="settings_debrid_description_template_description">Contrôle les métadonnées affichées sous chaque résultat.</string>
|
||||
<string name="settings_debrid_formatter_reset_title">Réinitialiser la mise en forme</string>
|
||||
<string name="settings_debrid_formatter_reset_subtitle">Restaurer la mise en forme par défaut des résultats.</string>
|
||||
<string name="settings_stream_badges_section">Style Fusion</string>
|
||||
<string name="settings_stream_size_badges_title">Badges de taille</string>
|
||||
<string name="settings_stream_size_badges_description">Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur.</string>
|
||||
<string name="settings_stream_addon_logo_title">Logo de l’addon</string>
|
||||
<string name="settings_stream_addon_logo_description">Affiche le logo et le nom de l’addon à côté des sources de streams.</string>
|
||||
<string name="settings_stream_display_section">Affichage</string>
|
||||
<string name="settings_stream_badge_position_title">Position des badges</string>
|
||||
<string name="settings_stream_badge_position_description">Choisis si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">Position des badges</string>
|
||||
<string name="settings_stream_badge_position_dialog_description">Choisis où les badges de flux apparaissent sur les cartes de flux.</string>
|
||||
<string name="settings_stream_badge_position_top">En haut</string>
|
||||
<string name="settings_stream_badge_position_bottom">En bas</string>
|
||||
<string name="settings_stream_badge_urls_title">URL de badges Fusion</string>
|
||||
<string name="settings_stream_badge_urls_description">Importe jusqu’à %1$d URL JSON de badges de flux de style Fusion. Chaque URL peut être mise à jour ou supprimée séparément.</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Gère les URL JSON de badges de flux de style Fusion importées.</string>
|
||||
<string name="settings_stream_badge_import_failed">Échec de l’import du badge.</string>
|
||||
<string name="settings_stream_badge_enter_url">Saisis une URL JSON de badge.</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">L’URL du badge doit commencer par http:// ou https://.</string>
|
||||
<string name="settings_stream_badge_import_limit">Tu peux importer jusqu’à %1$d URL de badges.</string>
|
||||
<string name="settings_fusion_badges_summary">%1$d/%2$d URL, %3$d badges Fusion actifs</string>
|
||||
<string name="settings_fusion_badges_empty">Aucune URL de badge Fusion importée.</string>
|
||||
<string name="settings_fusion_badge_url_label">URL JSON du badge Fusion</string>
|
||||
<string name="settings_fusion_badge_urls_imported">%1$d/%2$d URL Fusion importées</string>
|
||||
<string name="settings_fusion_badge_url_active">Active</string>
|
||||
<string name="settings_fusion_badge_url_inactive">Inactive</string>
|
||||
<string name="settings_fusion_badge_url_status_summary">%1$s · %2$d badges activés · %3$d groupes</string>
|
||||
<string name="settings_fusion_badge_preview_action">Aperçu</string>
|
||||
<string name="settings_fusion_badge_preview_title">Aperçu du badge Fusion</string>
|
||||
<string name="settings_fusion_badge_preview_count">%1$d badges de style Fusion depuis cette URL</string>
|
||||
<string name="settings_fusion_badge_preview_empty">Aucune image de badge de style Fusion dans cette URL.</string>
|
||||
<string name="settings_fusion_badge_group_title">Groupe %1$d</string>
|
||||
<string name="settings_fusion_badge_other_group_title">Autres badges Fusion</string>
|
||||
<string name="settings_debrid_key_valid">Clé API validée.</string>
|
||||
<string name="settings_debrid_key_invalid">Impossible de valider cette clé API.</string>
|
||||
<string name="settings_mdb_add_api_key_first">Ajoute ta clé API MDBList ci-dessous avant d’activer les notes.</string>
|
||||
|
|
@ -669,6 +727,8 @@
|
|||
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
|
||||
<string name="settings_meta_details">Détails</string>
|
||||
<string name="settings_meta_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
|
||||
<string name="settings_meta_hero_trailer_playback">Lecture des bandes-annonces dans le Hero</string>
|
||||
<string name="settings_meta_hero_trailer_playback_description">Lit un aperçu de la bande-annonce dans le Hero des métadonnées quand une bande-annonce est disponible.</string>
|
||||
<string name="settings_meta_episode_cards">Cartes d’épisodes</string>
|
||||
<string name="settings_meta_episode_cards_description">Choisis comment les épisodes sont affichés sur l’écran de métadonnées.</string>
|
||||
<string name="settings_meta_episode_style_horizontal">Horizontal</string>
|
||||
|
|
@ -768,10 +828,18 @@
|
|||
<string name="settings_playback_external_player_app">App de lecteur externe</string>
|
||||
<string name="settings_playback_external_player_description_android">Ouvre les nouvelles lectures avec l’app vidéo par défaut d’Android ou le sélecteur système.</string>
|
||||
<string name="settings_playback_external_player_description_ios">Ouvre les nouvelles lectures avec le lecteur installé sélectionné.</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Transmettre les sous-titres au lecteur externe</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Récupère les sous-titres d’addons dans ta langue préférée et les transmet au lecteur externe.</string>
|
||||
<string name="settings_playback_external_player_none_available">Aucun lecteur externe compatible n’est installé</string>
|
||||
<string name="settings_playback_player_preference">Lecteur</string>
|
||||
<string name="settings_playback_player_preference_internal">Interne</string>
|
||||
<string name="settings_playback_player_preference_external">Externe</string>
|
||||
<string name="settings_playback_player_preference_description">Choisis quel lecteur gère les nouvelles lectures.</string>
|
||||
<string name="settings_playback_hold_speed">Vitesse au maintien</string>
|
||||
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
|
||||
<string name="settings_playback_hold_to_speed_description">Garde le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement.</string>
|
||||
<string name="settings_playback_touch_gestures">Gestes tactiles</string>
|
||||
<string name="settings_playback_touch_gestures_description">Autorise les balayages et les doubles appuis sur le lecteur pour avancer ou reculer, ajuster la luminosité ou le volume.</string>
|
||||
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
|
||||
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
|
||||
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
|
||||
|
|
@ -785,6 +853,13 @@
|
|||
<string name="settings_playback_option_device_language">Langue de l’appareil</string>
|
||||
<string name="settings_playback_option_forced">Forcé</string>
|
||||
<string name="settings_playback_option_none">Aucun</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all">Tous les sous-titres</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all_description">Récupère et affiche tous les sous-titres d’addons pour la vidéo.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast">Démarrage rapide</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast_description">Ignore la récupération automatique des sous-titres d’addons jusqu’à ce que tu la demandes dans le lecteur.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_mode">Sous-titres d’addons au démarrage</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred">Préférés uniquement</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred_description">Récupère les sous-titres d’addons, mais n’affiche que ceux dans tes langues préférées.</string>
|
||||
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
|
||||
<string name="settings_playback_prefer_binge_group_description">Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel.</string>
|
||||
<string name="settings_playback_reuse_binge_group">Réutiliser le groupe de binge</string>
|
||||
|
|
@ -829,6 +904,20 @@
|
|||
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
|
||||
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
|
||||
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d’un stream.</string>
|
||||
<string name="settings_playback_subtitle_background_color">Couleur d’arrière-plan</string>
|
||||
<string name="settings_playback_subtitle_bold">Gras</string>
|
||||
<string name="settings_playback_subtitle_bold_description">Utilise une graisse de police plus épaisse pour les sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_color_transparent">Transparent</string>
|
||||
<string name="settings_playback_subtitle_outline">Contour</string>
|
||||
<string name="settings_playback_subtitle_outline_color">Couleur du contour</string>
|
||||
<string name="settings_playback_subtitle_outline_description">Dessine une bordure autour du texte des sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only">Afficher uniquement les langues préférées</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only_description">N’affiche que les sous-titres correspondant à tes langues de sous-titres préférées.</string>
|
||||
<string name="settings_playback_subtitle_size">Taille des sous-titres</string>
|
||||
<string name="settings_playback_subtitle_text_color">Couleur du texte</string>
|
||||
<string name="settings_playback_subtitle_use_forced">Utiliser les sous-titres forcés</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Privilégie les sous-titres forcés selon tes préférences de langue de sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_vertical_offset">Décalage vertical</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap">Passer l’intro/outro/récap</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d’intro, d’outro et de récapitulatif détectés.</string>
|
||||
<string name="settings_playback_source_scope">Périmètre des sources</string>
|
||||
|
|
@ -928,6 +1017,12 @@
|
|||
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">Source de progression réglée sur Trakt</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">Source de progression réglée sur Nuvio Sync</string>
|
||||
<string name="trakt_more_like_this_source_title">Source de la section Similaires</string>
|
||||
<string name="trakt_more_like_this_source_subtitle">Choisis la provenance des recommandations sur les pages de détails</string>
|
||||
<string name="trakt_more_like_this_source_dialog_title">Source de la section Similaires</string>
|
||||
<string name="trakt_more_like_this_source_dialog_subtitle">Sélectionne la source des recommandations affichées sur les pages de détails.</string>
|
||||
<string name="trakt_more_like_this_source_trakt">Trakt</string>
|
||||
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
|
||||
<string name="trakt_continue_watching_window">Fenêtre de Continuer à regarder</string>
|
||||
<string name="trakt_continue_watching_subtitle">Historique Trakt pris en compte pour Continuer à regarder</string>
|
||||
<string name="trakt_cw_window_title">Fenêtre de Continuer à regarder</string>
|
||||
|
|
@ -1049,10 +1144,13 @@
|
|||
<string name="app_exit_title">Quitter l’application</string>
|
||||
<string name="catalog_empty_message">Ce catalogue n’a renvoyé aucun élément.</string>
|
||||
<string name="catalog_empty_title">Aucun titre trouvé</string>
|
||||
<string name="details_actions_menu_label">Plus d’actions</string>
|
||||
<string name="details_check_connection">Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie.</string>
|
||||
<string name="details_director">Réalisateur</string>
|
||||
<string name="details_failed_to_load">Échec du chargement</string>
|
||||
<string name="details_more_like_this">À voir aussi</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Données fournies par TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Données fournies par Trakt</string>
|
||||
<string name="details_seasons">Saisons</string>
|
||||
<string name="details_series_missing_numbers">Cet addon a renvoyé des vidéos pour la série, mais aucune n’incluait de numéros de saison ou d’épisode.</string>
|
||||
<string name="details_series_no_metadata">Cet addon n’a fourni aucune métadonnée d’épisode pour cette série.</string>
|
||||
|
|
@ -1084,6 +1182,8 @@
|
|||
<string name="episode_mark_watched">Marquer comme vu</string>
|
||||
<string name="home_continue_watching_up_next">À suivre</string>
|
||||
<string name="home_continue_watching_watched">%1$s vu</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$d h %2$d min restantes</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$d min restantes</string>
|
||||
<string name="home_empty_no_active_addons_message">Installe et valide au moins un addon avant de charger des lignes de catalogue à l’accueil.</string>
|
||||
<string name="home_empty_no_rows_message">Les addons installés n’exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
|
||||
<string name="home_empty_no_rows_title">Aucune ligne d’accueil disponible</string>
|
||||
|
|
@ -1179,6 +1279,7 @@
|
|||
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
|
||||
<string name="streams_fetching">Récupération…</string>
|
||||
<string name="streams_finding_source">Recherche de la source…</string>
|
||||
<string name="streams_loading_subtitles">Chargement des sous-titres depuis les addons…</string>
|
||||
<string name="streams_finding_streams">Recherche des streams…</string>
|
||||
<string name="streams_link_copied">Lien du stream copié</string>
|
||||
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
|
||||
|
|
@ -1283,6 +1384,16 @@
|
|||
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
|
||||
<string name="trakt_user_fallback">Utilisateur Trakt</string>
|
||||
<string name="trakt_watchlist">Liste de suivi</string>
|
||||
<!-- Trakt library sync errors -->
|
||||
<string name="trakt_error_authorization_expired">Autorisation Trakt expirée</string>
|
||||
<string name="trakt_error_list_not_found">Liste Trakt introuvable</string>
|
||||
<string name="trakt_error_list_limit_reached">Limite de listes Trakt atteinte</string>
|
||||
<string name="trakt_error_rate_limit_reached">Limite de requêtes Trakt atteinte</string>
|
||||
<string name="trakt_error_request_failed">Échec de la requête Trakt</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Échec de l’ajout à ta liste de suivi Trakt</string>
|
||||
<string name="trakt_error_add_list_failed">Échec de l’ajout à la liste Trakt</string>
|
||||
<string name="trakt_error_missing_ids">Identifiants Trakt compatibles manquants</string>
|
||||
<string name="trakt_error_empty_response">Réponse vide du serveur</string>
|
||||
<string name="tmdb_sources_api_key_required">Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">Collection TMDB %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">Collection TMDB introuvable</string>
|
||||
|
|
@ -1483,6 +1594,11 @@
|
|||
<string name="settings_playback_ios_display_color_hint_desc">Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif.</string>
|
||||
<string name="settings_playback_ios_target_primaries">Primaires cibles</string>
|
||||
<string name="settings_playback_ios_target_transfer">Transfert cible</string>
|
||||
<string name="settings_playback_ios_audio_output_section">Sortie audio iOS</string>
|
||||
<string name="settings_playback_ios_audio_output">Sortie audio</string>
|
||||
<string name="settings_playback_ios_audio_output_auto_desc">Essaie d’abord AVFoundation, puis bascule sur AudioUnit.</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">Prise en charge expérimentale de l’audio spatial et de la sortie multicanal.</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">Utilise l’ancienne sortie AudioUnit.</string>
|
||||
<!-- Debrid Result Management section -->
|
||||
<string name="settings_debrid_section_result_management">Gestion des résultats</string>
|
||||
<string name="settings_debrid_max_results">Nombre max de résultats</string>
|
||||
|
|
@ -1498,6 +1614,7 @@
|
|||
<!-- Debrid misc -->
|
||||
<string name="settings_debrid_learn_more">En savoir plus</string>
|
||||
<string name="settings_debrid_template_default_format">Format par défaut</string>
|
||||
<string name="settings_debrid_template_original_format">Format d’origine</string>
|
||||
<string name="settings_debrid_release_groups_hint">Saisis un groupe par ligne.</string>
|
||||
<!-- Debrid sort profiles -->
|
||||
<string name="settings_debrid_sort_original">Ordre original</string>
|
||||
|
|
@ -1582,6 +1699,7 @@
|
|||
<string name="submit_intro_capture">Capturer</string>
|
||||
<!-- iOS advanced playback -->
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Décodeur matériel</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">Sortie audio</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Primaires cibles</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Transfert cible</string>
|
||||
<!-- Collection editor -->
|
||||
|
|
@ -1760,4 +1878,21 @@
|
|||
<!-- Pass 4 — platform stubs and dynamic title fallbacks -->
|
||||
<string name="collections_editor_trakt_list_title_format">Liste Trakt %1$s</string>
|
||||
<string name="external_player_android_system">Lecteur système Android</string>
|
||||
<string name="p2p_consent_title">Streaming P2P</string>
|
||||
<string name="p2p_consent_body">Ce flux utilise la technologie pair-à-pair (P2P). En activant le P2P, tu reconnais et acceptes que :\n\n• Ton adresse IP sera visible par les autres pairs du réseau\n• Tu es seul responsable du contenu auquel tu accèdes\n• Tu confirmes avoir le droit légal de visionner ce contenu dans ta juridiction\n• Nuvio n’héberge, ne distribue ni ne contrôle aucun contenu P2P\n• Nuvio ne peut être tenu responsable des conséquences légales liées à ton utilisation du streaming P2P\n\nTu utilises cette fonctionnalité entièrement à tes propres risques. Le P2P peut être désactivé à tout moment dans les paramètres.</string>
|
||||
<string name="p2p_consent_enable">Activer le P2P</string>
|
||||
<string name="p2p_consent_cancel">Annuler</string>
|
||||
<string name="p2p_error_unknown">Erreur torrent inconnue</string>
|
||||
<string name="settings_p2p_title">Streaming P2P</string>
|
||||
<string name="settings_p2p_subtitle">Autoriser les flux pair-à-pair (torrent)</string>
|
||||
<string name="settings_p2p_hide_stats_title">Masquer les statistiques torrent</string>
|
||||
<string name="settings_p2p_hide_stats_subtitle">Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture</string>
|
||||
<string name="player_torrent_peer_info">%1$d sources · %2$d pairs</string>
|
||||
<string name="player_torrent_buffered_status">%1$s en mémoire tampon · %2$s · %3$s</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_torrent_stats">%1$d pairs · %2$d sources · %3$d %%</string>
|
||||
<string name="player_torrent_connecting_peers">Connexion aux pairs…</string>
|
||||
<string name="player_torrent_starting_engine">Démarrage du moteur P2P…</string>
|
||||
<string name="player_error_failed_start_torrent">Impossible de démarrer le torrent : %1$s</string>
|
||||
<string name="player_error_torrent">Erreur de torrent : %1$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -300,7 +300,6 @@
|
|||
<string name="collections_pinned">Pinned</string>
|
||||
<string name="collections_tab_all">All</string>
|
||||
<string name="collections_your_collections">Your Collections</string>
|
||||
<string name="compose_about_based_on_version_format">Based on Nuvio %1$s</string>
|
||||
<string name="compose_about_made_with">Made with ❤️ by Tapframe and friends</string>
|
||||
<string name="compose_about_version_format">Version %1$s (%2$s)</string>
|
||||
<string name="compose_action_off">Off</string>
|
||||
|
|
@ -345,6 +344,8 @@
|
|||
<string name="compose_player_font_size_value">%1$dsp</string>
|
||||
<string name="compose_player_lock_controls">Lock player controls</string>
|
||||
<string name="compose_player_loading_lines">Loading subtitle lines...</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">No subtitle lines found</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Unable to load subtitle lines</string>
|
||||
<string name="compose_player_no_audio_tracks_available">No audio tracks available</string>
|
||||
<string name="compose_player_no_episodes_available">No episodes available</string>
|
||||
<string name="compose_player_no_streams_found">No streams found</string>
|
||||
|
|
@ -450,7 +451,7 @@
|
|||
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached thumbnails, titles, and enrichment data for Continue Watching</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>
|
||||
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
|
||||
<string name="settings_licenses_attributions_section_data">DATA & SERVICES</string>
|
||||
|
|
@ -536,10 +537,6 @@
|
|||
<string name="settings_appearance_app_language">App Language</string>
|
||||
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
|
||||
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
|
||||
<string name="settings_appearance_desktop_navigation">Desktop Navigation</string>
|
||||
<string name="settings_appearance_desktop_navigation_sheet_title">Choose Desktop Navigation</string>
|
||||
<string name="settings_appearance_desktop_navigation_sidebar">Sidebar</string>
|
||||
<string name="settings_appearance_desktop_navigation_top_bar">Top Bar</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on.</string>
|
||||
<string name="settings_appearance_poster_customization_description">Tune card width and corner radius.</string>
|
||||
|
|
@ -698,6 +695,10 @@
|
|||
<string name="settings_stream_badge_urls_title">Fusion badge URLs</string>
|
||||
<string name="settings_stream_badge_urls_description">Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately.</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Manage imported Fusion-style stream badge JSON URLs.</string>
|
||||
<string name="settings_stream_badge_import_failed">Badge import failed.</string>
|
||||
<string name="settings_stream_badge_enter_url">Enter a badge JSON URL.</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">Badge URL must start with http:// or https://.</string>
|
||||
<string name="settings_stream_badge_import_limit">You can import up to %1$d badge URLs.</string>
|
||||
<string name="settings_fusion_badges_summary">%1$d/%2$d URLs, %3$d active Fusion badges</string>
|
||||
<string name="settings_fusion_badges_empty">No Fusion badge URLs imported.</string>
|
||||
<string name="settings_fusion_badge_url_label">Fusion badge JSON URL</string>
|
||||
|
|
@ -845,6 +846,8 @@
|
|||
<string name="settings_playback_hold_speed">Hold Speed</string>
|
||||
<string name="settings_playback_hold_to_speed">Hold To Speed</string>
|
||||
<string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string>
|
||||
<string name="settings_playback_touch_gestures">Touch Gestures</string>
|
||||
<string name="settings_playback_touch_gestures_description">Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust volume.</string>
|
||||
<string name="settings_playback_invalid_regex_pattern">Invalid regex pattern</string>
|
||||
<string name="settings_playback_last_link_cache_duration">Last Link Cache Duration</string>
|
||||
<string name="settings_playback_map_dv7_to_hevc">DV7 - HEVC Fallback</string>
|
||||
|
|
@ -1149,6 +1152,7 @@
|
|||
<string name="app_exit_title">Exit app</string>
|
||||
<string name="catalog_empty_message">This catalog did not return any items.</string>
|
||||
<string name="catalog_empty_title">No titles found</string>
|
||||
<string name="details_actions_menu_label">More actions</string>
|
||||
<string name="details_check_connection">Check your Wi-Fi or mobile data connection and try again.</string>
|
||||
<string name="details_director">Director</string>
|
||||
<string name="details_failed_to_load">Failed to load</string>
|
||||
|
|
@ -1392,6 +1396,16 @@
|
|||
<string name="trakt_sign_in_complete_failed">Failed to complete Trakt sign in</string>
|
||||
<string name="trakt_user_fallback">Trakt user</string>
|
||||
<string name="trakt_watchlist">Watchlist</string>
|
||||
<!-- Trakt library sync errors -->
|
||||
<string name="trakt_error_authorization_expired">Trakt authorization expired</string>
|
||||
<string name="trakt_error_list_not_found">Trakt list not found</string>
|
||||
<string name="trakt_error_list_limit_reached">Trakt list limit reached</string>
|
||||
<string name="trakt_error_rate_limit_reached">Trakt rate limit reached</string>
|
||||
<string name="trakt_error_request_failed">Trakt request failed</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Failed to add to Trakt watchlist</string>
|
||||
<string name="trakt_error_add_list_failed">Failed to add to Trakt list</string>
|
||||
<string name="trakt_error_missing_ids">Missing compatible Trakt IDs</string>
|
||||
<string name="trakt_error_empty_response">Empty response body</string>
|
||||
<string name="tmdb_sources_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">TMDB Collection %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">TMDB collection not found</string>
|
||||
|
|
@ -1865,6 +1879,7 @@
|
|||
<string name="p2p_consent_body">This stream uses peer-to-peer (P2P) technology. By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You confirm you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears no liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P can be disabled anytime in Settings.</string>
|
||||
<string name="p2p_consent_enable">Enable P2P</string>
|
||||
<string name="p2p_consent_cancel">Cancel</string>
|
||||
<string name="p2p_error_unknown">Unknown torrent error</string>
|
||||
<string name="settings_p2p_title">P2P Streaming</string>
|
||||
<string name="settings_p2p_subtitle">Allow peer-to-peer (torrent) streams</string>
|
||||
<string name="settings_p2p_hide_stats_title">Hide torrent stats</string>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,5 +6,4 @@ interface Platform {
|
|||
|
||||
expect fun getPlatform(): Platform
|
||||
|
||||
internal expect val isIos: Boolean
|
||||
internal expect val isDesktop: Boolean
|
||||
internal expect val isIos: Boolean
|
||||
|
|
@ -7,8 +7,6 @@ enum class TrailerPlaybackMode {
|
|||
|
||||
expect object AppFeaturePolicy {
|
||||
val pluginsEnabled: Boolean
|
||||
val downloadsEnabled: Boolean
|
||||
val notificationsEnabled: Boolean
|
||||
val p2pEnabled: Boolean
|
||||
val trailerPlaybackMode: TrailerPlaybackMode
|
||||
val heroTrailerPlaybackSupported: Boolean
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
expect object AppVersionPolicy {
|
||||
val displayVersionName: String
|
||||
val displayVersionCode: Int
|
||||
val basedOnVersionName: String?
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import nuvio.composeapp.generated.resources.action_resume
|
|||
import nuvio.composeapp.generated.resources.action_resume_episode
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found
|
||||
import nuvio.composeapp.generated.resources.compose_player_subtitle_lines_load_error
|
||||
import nuvio.composeapp.generated.resources.continue_watching_up_next
|
||||
import nuvio.composeapp.generated.resources.continue_watching_up_next_episode
|
||||
import nuvio.composeapp.generated.resources.date_month_april
|
||||
|
|
@ -40,6 +42,11 @@ import nuvio.composeapp.generated.resources.media_movie
|
|||
import nuvio.composeapp.generated.resources.media_movies
|
||||
import nuvio.composeapp.generated.resources.media_series
|
||||
import nuvio.composeapp.generated.resources.media_tv
|
||||
import nuvio.composeapp.generated.resources.p2p_error_unknown
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_enter_url
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_import_failed
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_import_limit
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_url_scheme_invalid
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_b
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_gb
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_kb
|
||||
|
|
@ -134,6 +141,27 @@ fun localizedShortMonthName(month: Int): String =
|
|||
else -> month.toString()
|
||||
}
|
||||
|
||||
fun localizedNoSubtitleLinesFound(): String =
|
||||
resourceString("No subtitle lines found") { getString(Res.string.compose_player_no_subtitle_lines_found) }
|
||||
|
||||
fun localizedSubtitleLinesLoadError(): String =
|
||||
resourceString("Unable to load subtitle lines") { getString(Res.string.compose_player_subtitle_lines_load_error) }
|
||||
|
||||
fun localizedBadgeImportFailed(): String =
|
||||
resourceString("Badge import failed.") { getString(Res.string.settings_stream_badge_import_failed) }
|
||||
|
||||
fun localizedBadgeEnterUrl(): String =
|
||||
resourceString("Enter a badge JSON URL.") { getString(Res.string.settings_stream_badge_enter_url) }
|
||||
|
||||
fun localizedBadgeUrlSchemeInvalid(): String =
|
||||
resourceString("Badge URL must start with http:// or https://.") { getString(Res.string.settings_stream_badge_url_scheme_invalid) }
|
||||
|
||||
fun localizedBadgeImportLimit(limit: Int): String =
|
||||
resourceString("You can import up to $limit badge URLs.") { getString(Res.string.settings_stream_badge_import_limit, limit) }
|
||||
|
||||
fun localizedP2pUnknownTorrentError(): String =
|
||||
resourceString("Unknown torrent error") { getString(Res.string.p2p_error_unknown) }
|
||||
|
||||
fun localizedByteUnit(unit: String): String =
|
||||
when (unit) {
|
||||
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
|
|
@ -79,17 +78,8 @@ object ProfileSettingsSync {
|
|||
@Volatile
|
||||
private var skipNextPushSignature: String? = null
|
||||
|
||||
@Volatile
|
||||
private var preservedRemotePlayerSettings: JsonObject? = null
|
||||
|
||||
@Volatile
|
||||
private var preservedRemotePlayerSettingsProfileId: Int? = null
|
||||
|
||||
private var observeJob: Job? = null
|
||||
|
||||
private val syncPlayerSettings: Boolean
|
||||
get() = !isDesktop
|
||||
|
||||
fun startObserving() {
|
||||
if (observeJob?.isActive == true) return
|
||||
ensureRepositoriesLoaded()
|
||||
|
|
@ -101,13 +91,19 @@ object ProfileSettingsSync {
|
|||
return syncMutex.withLock {
|
||||
isServerSyncInFlight = true
|
||||
try {
|
||||
val remoteJson = fetchRemoteSettingsJson(profileId)
|
||||
val localBlob = exportSettingsBlob()
|
||||
val localSignature = buildSignature(localBlob)
|
||||
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params)
|
||||
val response = result.decodeList<SettingsBlobResponse>().firstOrNull()
|
||||
val remoteJson = response?.settingsJson
|
||||
|
||||
if (remoteJson == null) {
|
||||
log.i { "pull(profileId=$profileId) — no remote settings blob found" }
|
||||
clearPreservedRemotePlayerSettings(profileId)
|
||||
val localBlob = exportSettingsBlob(profileId)
|
||||
val localSignature = buildSignature(localBlob)
|
||||
if (localSignature != defaultSignature()) {
|
||||
pushToRemoteLocked(profileId, localBlob)
|
||||
}
|
||||
|
|
@ -123,17 +119,13 @@ object ProfileSettingsSync {
|
|||
return@withLock false
|
||||
}
|
||||
|
||||
preserveRemotePlayerSettings(profileId, remoteBlob)
|
||||
|
||||
val localBlob = exportSettingsBlob(profileId)
|
||||
val localSignature = buildSignature(localBlob)
|
||||
val remoteSignature = buildSignature(remoteBlob)
|
||||
if (remoteSignature == localSignature) {
|
||||
log.d { "pull(profileId=$profileId) — remote matches local" }
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
applyRemoteBlob(profileId, remoteBlob)
|
||||
applyRemoteBlob(remoteBlob)
|
||||
skipNextPushSignature = currentObservedStateSignature()
|
||||
} finally {
|
||||
isApplyingRemoteBlob = false
|
||||
|
|
@ -154,8 +146,7 @@ object ProfileSettingsSync {
|
|||
ensureRepositoriesLoaded()
|
||||
syncMutex.withLock {
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
pushToRemoteLocked(profileId, exportSettingsBlob(profileId))
|
||||
pushToRemoteLocked(ProfileRepository.activeProfileId, exportSettingsBlob())
|
||||
}.onFailure { error ->
|
||||
log.e(error) { "pushCurrentProfileToRemote() — FAILED" }
|
||||
}
|
||||
|
|
@ -164,25 +155,23 @@ object ProfileSettingsSync {
|
|||
|
||||
@OptIn(FlowPreview::class)
|
||||
private fun observeLocalChangesAndPush() {
|
||||
val signatureFlows = buildList {
|
||||
add(ThemeSettingsRepository.selectedTheme.map { "theme" })
|
||||
add(ThemeSettingsRepository.amoledEnabled.map { "amoled" })
|
||||
add(ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" })
|
||||
add(PosterCardStyleRepository.uiState.map { "poster_card_style" })
|
||||
if (syncPlayerSettings) {
|
||||
add(PlayerSettingsRepository.uiState.map { "player" })
|
||||
}
|
||||
add(StreamBadgeSettingsRepository.uiState.map { "stream_badges" })
|
||||
add(DebridSettingsRepository.uiState.map { "debrid" })
|
||||
add(TmdbSettingsRepository.uiState.map { "tmdb" })
|
||||
add(MdbListSettingsRepository.uiState.map { "mdblist" })
|
||||
add(MetaScreenSettingsRepository.uiState.map { "meta" })
|
||||
add(CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" })
|
||||
add(ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" })
|
||||
add(TraktSettingsRepository.uiState.map { "trakt_settings" })
|
||||
add(TraktCommentsSettings.enabled.map { "trakt_comments" })
|
||||
add(EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" })
|
||||
}
|
||||
val signatureFlows = listOf(
|
||||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
StreamBadgeSettingsRepository.uiState.map { "stream_badges" },
|
||||
DebridSettingsRepository.uiState.map { "debrid" },
|
||||
TmdbSettingsRepository.uiState.map { "tmdb" },
|
||||
MdbListSettingsRepository.uiState.map { "mdblist" },
|
||||
MetaScreenSettingsRepository.uiState.map { "meta" },
|
||||
CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" },
|
||||
ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" },
|
||||
TraktSettingsRepository.uiState.map { "trakt_settings" },
|
||||
TraktCommentsSettings.enabled.map { "trakt_comments" },
|
||||
EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" },
|
||||
)
|
||||
|
||||
observeJob = scope.launch {
|
||||
combine(signatureFlows) { currentObservedStateSignature() }
|
||||
|
|
@ -203,23 +192,22 @@ object ProfileSettingsSync {
|
|||
}
|
||||
|
||||
private suspend fun pushToRemoteLocked(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
val blobToPush = withPreservedDesktopPlayerSettings(profileId, blob)
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blobToPush))
|
||||
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob))
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params)
|
||||
log.d { "pushToRemoteLocked(profileId=$profileId) — success" }
|
||||
}
|
||||
|
||||
private fun exportSettingsBlob(profileId: Int = ProfileRepository.activeProfileId): MobileProfileSettingsBlob {
|
||||
private fun exportSettingsBlob(): MobileProfileSettingsBlob {
|
||||
ensureRepositoriesLoaded()
|
||||
return MobileProfileSettingsBlob(
|
||||
features = MobileProfileSettingsFeatures(
|
||||
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
||||
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
||||
playerSettings = exportPlayerSettingsPayload(profileId),
|
||||
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
||||
streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(),
|
||||
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
|
||||
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
|
||||
|
|
@ -236,19 +224,15 @@ object ProfileSettingsSync {
|
|||
)
|
||||
}
|
||||
|
||||
private fun applyRemoteBlob(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
private fun applyRemoteBlob(blob: MobileProfileSettingsBlob) {
|
||||
ThemeSettingsStorage.replaceFromSyncPayload(blob.features.themeSettings)
|
||||
ThemeSettingsRepository.onProfileChanged()
|
||||
|
||||
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
|
||||
PosterCardStyleRepository.onProfileChanged()
|
||||
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||
PlayerSettingsRepository.onProfileChanged()
|
||||
} else {
|
||||
preserveRemotePlayerSettings(profileId, blob)
|
||||
}
|
||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||
PlayerSettingsRepository.onProfileChanged()
|
||||
|
||||
StreamBadgeSettingsStorage.replaceFromSyncPayload(blob.features.streamBadgeSettings)
|
||||
StreamBadgeSettingsRepository.onProfileChanged()
|
||||
|
|
@ -284,9 +268,7 @@ object ProfileSettingsSync {
|
|||
private fun ensureRepositoriesLoaded() {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
PosterCardStyleRepository.ensureLoaded()
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
}
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
TmdbSettingsRepository.ensureLoaded()
|
||||
|
|
@ -305,90 +287,23 @@ object ProfileSettingsSync {
|
|||
private fun defaultSignature(): String =
|
||||
buildSignature(MobileProfileSettingsBlob())
|
||||
|
||||
private fun currentObservedStateSignature(): String = buildList {
|
||||
add("theme=${ThemeSettingsRepository.selectedTheme.value.name}")
|
||||
add("amoled=${ThemeSettingsRepository.amoledEnabled.value}")
|
||||
add("liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}")
|
||||
add("poster_card_style=${PosterCardStyleRepository.uiState.value}")
|
||||
if (syncPlayerSettings) {
|
||||
add("player=${PlayerSettingsRepository.uiState.value}")
|
||||
}
|
||||
add("stream_badges=${StreamBadgeSettingsRepository.uiState.value}")
|
||||
add("debrid=${DebridSettingsRepository.uiState.value}")
|
||||
add("tmdb=${TmdbSettingsRepository.uiState.value}")
|
||||
add("mdblist=${MdbListSettingsRepository.uiState.value}")
|
||||
add("meta=${MetaScreenSettingsRepository.uiState.value}")
|
||||
add("collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}")
|
||||
add("continue=${ContinueWatchingPreferencesRepository.uiState.value}")
|
||||
add("trakt_settings=${TraktSettingsRepository.uiState.value}")
|
||||
add("trakt_comments=${TraktCommentsSettings.enabled.value}")
|
||||
add("episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}")
|
||||
}.joinToString(separator = "||")
|
||||
|
||||
private fun exportPlayerSettingsPayload(profileId: Int): JsonObject =
|
||||
if (syncPlayerSettings) {
|
||||
PlayerSettingsStorage.exportToSyncPayload()
|
||||
} else {
|
||||
preservedRemotePlayerSettingsFor(profileId) ?: JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
private fun preserveRemotePlayerSettings(profileId: Int, blob: MobileProfileSettingsBlob) {
|
||||
if (!syncPlayerSettings) {
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = blob.features.playerSettings
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPreservedRemotePlayerSettings(profileId: Int) {
|
||||
if (!syncPlayerSettings) {
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun preservedRemotePlayerSettingsFor(profileId: Int): JsonObject? =
|
||||
preservedRemotePlayerSettings
|
||||
?.takeIf { preservedRemotePlayerSettingsProfileId == profileId }
|
||||
|
||||
private suspend fun withPreservedDesktopPlayerSettings(
|
||||
profileId: Int,
|
||||
blob: MobileProfileSettingsBlob,
|
||||
): MobileProfileSettingsBlob {
|
||||
if (syncPlayerSettings) return blob
|
||||
|
||||
val remoteBlobResult = runCatching {
|
||||
fetchRemoteSettingsJson(profileId)
|
||||
?.let { remoteJson ->
|
||||
json.decodeFromJsonElement(MobileProfileSettingsBlob.serializer(), remoteJson)
|
||||
}
|
||||
}
|
||||
val remotePlayerSettings = if (remoteBlobResult.isSuccess) {
|
||||
remoteBlobResult.getOrNull()?.features?.playerSettings ?: JsonObject(emptyMap())
|
||||
} else {
|
||||
val error = remoteBlobResult.exceptionOrNull()
|
||||
if (error != null) {
|
||||
log.e(error) { "pushToRemoteLocked(profileId=$profileId) — failed to preserve remote player settings" }
|
||||
}
|
||||
preservedRemotePlayerSettingsFor(profileId) ?: throw (error ?: IllegalStateException("Missing remote player settings"))
|
||||
}
|
||||
|
||||
preservedRemotePlayerSettingsProfileId = profileId
|
||||
preservedRemotePlayerSettings = remotePlayerSettings
|
||||
return blob.copy(
|
||||
features = blob.features.copy(
|
||||
playerSettings = remotePlayerSettings,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteSettingsJson(profileId: Int): JsonObject? {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_profile_settings_blob", params)
|
||||
return result.decodeList<SettingsBlobResponse>().firstOrNull()?.settingsJson
|
||||
}
|
||||
private fun currentObservedStateSignature(): String = listOf(
|
||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
"stream_badges=${StreamBadgeSettingsRepository.uiState.value}",
|
||||
"debrid=${DebridSettingsRepository.uiState.value}",
|
||||
"tmdb=${TmdbSettingsRepository.uiState.value}",
|
||||
"mdblist=${MdbListSettingsRepository.uiState.value}",
|
||||
"meta=${MetaScreenSettingsRepository.uiState.value}",
|
||||
"collection_mobile_settings=${CollectionMobileSettingsRepository.uiState.value}",
|
||||
"continue=${ContinueWatchingPreferencesRepository.uiState.value}",
|
||||
"trakt_settings=${TraktSettingsRepository.uiState.value}",
|
||||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||
).joinToString(separator = "||")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImagePainter
|
||||
|
||||
internal enum class NuvioDesktopImageScaling {
|
||||
Auto,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal expect fun NuvioAsyncImage(
|
||||
model: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
error: Painter? = null,
|
||||
fallback: Painter? = error,
|
||||
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
|
||||
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality? = null,
|
||||
clipToBounds: Boolean = true,
|
||||
desktopImageScaling: NuvioDesktopImageScaling = NuvioDesktopImageScaling.Auto,
|
||||
)
|
||||
|
|
@ -26,6 +26,7 @@ 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 coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
|
|
@ -132,7 +133,7 @@ private fun ContinueWatchingSheetHeader(
|
|||
) {
|
||||
val artwork = item.poster ?: item.imageUrl
|
||||
if (artwork != null) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = cloudLibraryDisplayArtworkUrl(artwork),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
|
@ -190,7 +191,7 @@ fun NuvioFloatingPrompt(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ 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.features.home.MetaPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -188,7 +189,7 @@ private fun PosterSheetHeader(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
|
|||
|
|
@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||
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.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
|
|
@ -30,20 +27,19 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.isDesktop
|
||||
import coil3.compose.AsyncImage
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.home_view_all
|
||||
import nuvio.composeapp.generated.resources.poster_logo_content_description
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class NuvioPosterShape {
|
||||
Poster,
|
||||
|
|
@ -71,7 +67,6 @@ fun <T> NuvioShelfSection(
|
|||
itemContent: @Composable (T) -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val rowState = rememberLazyListState()
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
|
||||
|
|
@ -86,8 +81,6 @@ fun <T> NuvioShelfSection(
|
|||
)
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.desktopShelfDragScroll(rowState),
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
) {
|
||||
|
|
@ -107,47 +100,6 @@ fun <T> NuvioShelfSection(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Modifier.desktopShelfDragScroll(
|
||||
state: LazyListState,
|
||||
): Modifier {
|
||||
if (!isDesktop) return this
|
||||
|
||||
return pointerInput(state) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(pass = PointerEventPass.Initial)
|
||||
var totalDx = 0f
|
||||
var totalDy = 0f
|
||||
var dragging = false
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
|
||||
val delta = change.position - change.previousPosition
|
||||
totalDx += delta.x
|
||||
totalDy += delta.y
|
||||
|
||||
if (!dragging) {
|
||||
val horizontalDrag =
|
||||
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
|
||||
val verticalDrag =
|
||||
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
|
||||
|
||||
when {
|
||||
verticalDrag -> break
|
||||
horizontalDrag -> dragging = true
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
|
||||
state.dispatchRawDelta(-delta.x)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioPosterCard(
|
||||
title: String,
|
||||
|
|
@ -186,7 +138,7 @@ fun NuvioPosterCard(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
@ -211,7 +163,7 @@ fun NuvioPosterCard(
|
|||
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10),
|
||||
) {
|
||||
if (!bottomLeftLogoUrl.isNullOrBlank()) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = bottomLeftLogoUrl,
|
||||
contentDescription = stringResource(Res.string.poster_logo_content_description, title),
|
||||
modifier = Modifier
|
||||
|
|
@ -268,38 +220,45 @@ private fun NuvioShelfSectionHeader(
|
|||
viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (showAccent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = NuvioTokens.Space.s6)
|
||||
.width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4)
|
||||
.height(NuvioTokens.Space.s4)
|
||||
.background(
|
||||
color = tokens.colors.accent,
|
||||
shape = tokens.shapes.chip,
|
||||
),
|
||||
)
|
||||
val viewAllPlaceholderModifier = if (onViewAllClick == null) {
|
||||
Modifier
|
||||
.alpha(0f)
|
||||
.clearAndSetSemantics { }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
if (onViewAllClick != null) {
|
||||
NuvioViewAllPill(
|
||||
onClick = onViewAllClick,
|
||||
size = viewAllPillSize,
|
||||
modifier = viewAllPlaceholderModifier,
|
||||
)
|
||||
}
|
||||
if (showAccent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = NuvioTokens.Space.s6)
|
||||
.width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4)
|
||||
.height(NuvioTokens.Space.s4)
|
||||
.background(
|
||||
color = tokens.colors.accent,
|
||||
shape = tokens.shapes.chip,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -309,38 +268,28 @@ private fun NuvioShelfSectionHeader(
|
|||
private fun NuvioViewAllPill(
|
||||
onClick: (() -> Unit)?,
|
||||
size: NuvioViewAllPillSize,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val horizontalPadding = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s12 else NuvioTokens.Space.s18
|
||||
val verticalPadding = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s8 + NuvioTokens.Space.s1 else NuvioTokens.Space.s14
|
||||
val textStyle = if (size == NuvioViewAllPillSize.Compact) {
|
||||
MaterialTheme.typography.labelLarge
|
||||
} else {
|
||||
MaterialTheme.typography.titleMedium
|
||||
}
|
||||
val iconSpacing = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s2 else NuvioTokens.Space.s4
|
||||
val actionSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s32 else NuvioTokens.Space.s40
|
||||
val iconSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md
|
||||
val viewAllText = stringResource(Res.string.home_view_all)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(actionSize)
|
||||
.background(
|
||||
color = tokens.colors.surface,
|
||||
shape = RoundedCornerShape(NuvioTokens.Radius.xl),
|
||||
)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(iconSpacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.home_view_all),
|
||||
style = textStyle,
|
||||
color = tokens.colors.textPrimary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
contentDescription = viewAllText,
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.height(if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md),
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -395,7 +344,6 @@ internal fun Modifier.posterCardClickable(
|
|||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.secondaryClick(onLongClick)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
internal expect fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier
|
||||
|
|
@ -44,7 +44,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioIconActionButton
|
||||
import com.nuvio.app.core.ui.NuvioInfoBadge
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
const val CATALOG_PAGE_SIZE = 100
|
||||
private const val DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT = 3
|
||||
|
||||
data class CatalogPage(
|
||||
val items: List<MetaPreview>,
|
||||
|
|
@ -22,6 +23,11 @@ data class CatalogPage(
|
|||
val nextSkip: Int?,
|
||||
)
|
||||
|
||||
data class CatalogPaginationState(
|
||||
val nextSkip: Int?,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
)
|
||||
|
||||
private val inflightMutex = Mutex()
|
||||
private val inflightRequestScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val inflightRequests = mutableMapOf<String, CompletableDeferred<String>>()
|
||||
|
|
@ -84,7 +90,40 @@ suspend fun fetchCatalogPage(
|
|||
}
|
||||
|
||||
fun AddonCatalog.supportsPagination(): Boolean =
|
||||
extra.any { property -> property.name == "skip" }
|
||||
extra.any { property -> property.name.equals("skip", ignoreCase = true) }
|
||||
|
||||
fun nextCatalogPaginationState(
|
||||
supportsPagination: Boolean,
|
||||
requestedSkip: Int,
|
||||
page: CatalogPage,
|
||||
loadedNewItems: Boolean,
|
||||
consecutiveDuplicatePages: Int,
|
||||
): CatalogPaginationState {
|
||||
if (!supportsPagination || page.rawItemCount <= 0 || page.nextSkip == null) {
|
||||
return CatalogPaginationState(nextSkip = null)
|
||||
}
|
||||
if (loadedNewItems) {
|
||||
return CatalogPaginationState(nextSkip = page.nextSkip)
|
||||
}
|
||||
|
||||
val duplicatePages = consecutiveDuplicatePages + 1
|
||||
val advancedSkip = if (page.nextSkip > requestedSkip) {
|
||||
page.nextSkip
|
||||
} else {
|
||||
requestedSkip + page.rawItemCount.coerceAtLeast(1)
|
||||
}
|
||||
return if (duplicatePages < DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT && advancedSkip > requestedSkip) {
|
||||
CatalogPaginationState(
|
||||
nextSkip = advancedSkip,
|
||||
consecutiveDuplicatePages = duplicatePages,
|
||||
)
|
||||
} else {
|
||||
CatalogPaginationState(
|
||||
nextSkip = null,
|
||||
consecutiveDuplicatePages = duplicatePages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeCatalogItems(
|
||||
existing: List<MetaPreview>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ data class CatalogUiState(
|
|||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val canLoadMore: Boolean
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
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.toMetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -16,8 +20,6 @@ import kotlinx.coroutines.launch
|
|||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
|
||||
|
||||
object CatalogRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _uiState = MutableStateFlow(CatalogUiState())
|
||||
|
|
@ -28,25 +30,15 @@ object CatalogRepository {
|
|||
private val scrollPositions = linkedMapOf<CatalogRequest, CatalogScrollPosition>()
|
||||
|
||||
fun load(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
force: Boolean = false,
|
||||
) {
|
||||
val request = catalogRequest(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
val request = catalogRequest(target)
|
||||
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
|
||||
return
|
||||
}
|
||||
activeRequest = request
|
||||
if (manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) {
|
||||
if (target is CatalogTarget.Library) {
|
||||
fetchInternalLibrary(request)
|
||||
return
|
||||
}
|
||||
|
|
@ -68,25 +60,17 @@ object CatalogRepository {
|
|||
}
|
||||
|
||||
fun scrollPosition(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
): CatalogScrollPosition =
|
||||
scrollPositions[catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)]
|
||||
scrollPositions[catalogRequest(target)]
|
||||
?: CatalogScrollPosition()
|
||||
|
||||
fun saveScrollPosition(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
firstVisibleItemIndex: Int,
|
||||
firstVisibleItemScrollOffset: Int,
|
||||
) {
|
||||
val request = catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)
|
||||
val request = catalogRequest(target)
|
||||
scrollPositions[request] = CatalogScrollPosition(
|
||||
firstVisibleItemIndex = firstVisibleItemIndex,
|
||||
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
|
||||
|
|
@ -102,9 +86,10 @@ object CatalogRepository {
|
|||
|
||||
activeJob = scope.launch {
|
||||
runCatching {
|
||||
val target = request.target as CatalogTarget.Library
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState.value.sections
|
||||
.firstOrNull { it.type == request.catalogId }
|
||||
.firstOrNull { it.type == target.sectionType }
|
||||
?.items
|
||||
.orEmpty()
|
||||
.map { it.toMetaPreview() }
|
||||
|
|
@ -149,13 +134,22 @@ object CatalogRepository {
|
|||
|
||||
activeJob = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = request.manifestUrl,
|
||||
type = request.type,
|
||||
catalogId = request.catalogId,
|
||||
genre = request.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
).withUnreleasedFilter(request.hideUnreleasedContent)
|
||||
when (val target = request.target) {
|
||||
is CatalogTarget.Addon -> fetchCatalogPage(
|
||||
manifestUrl = target.manifestUrl,
|
||||
type = target.contentType,
|
||||
catalogId = target.catalogId,
|
||||
genre = target.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
|
||||
is CatalogTarget.CollectionSource -> fetchCollectionSourcePage(
|
||||
target = target,
|
||||
page = requestedSkip.takeIf { it > 0 } ?: 1,
|
||||
)
|
||||
|
||||
is CatalogTarget.Library -> error(getString(Res.string.catalog_load_failed))
|
||||
}.withUnreleasedFilter(request.hideUnreleasedContent)
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
|
@ -165,12 +159,20 @@ object CatalogRepository {
|
|||
} else {
|
||||
mergeCatalogItems(_uiState.value.items, page.items)
|
||||
}
|
||||
val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val supportsPagination = request.target.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val loadedNewItems = reset || mergedItems.size > current.items.size
|
||||
val paginationState = nextCatalogPaginationState(
|
||||
supportsPagination = supportsPagination,
|
||||
requestedSkip = requestedSkip,
|
||||
page = page,
|
||||
loadedNewItems = loadedNewItems,
|
||||
consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages,
|
||||
)
|
||||
_uiState.value = CatalogUiState(
|
||||
items = mergedItems,
|
||||
isLoading = false,
|
||||
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
|
||||
nextSkip = paginationState.nextSkip,
|
||||
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
|
||||
errorMessage = null,
|
||||
)
|
||||
},
|
||||
|
|
@ -188,19 +190,9 @@ object CatalogRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun catalogRequest(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
): CatalogRequest =
|
||||
private fun catalogRequest(target: CatalogTarget): CatalogRequest =
|
||||
CatalogRequest(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent,
|
||||
)
|
||||
}
|
||||
|
|
@ -211,11 +203,26 @@ private fun CatalogPage.withUnreleasedFilter(hideUnreleasedContent: Boolean): Ca
|
|||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private suspend fun fetchCollectionSourcePage(
|
||||
target: CatalogTarget.CollectionSource,
|
||||
page: Int,
|
||||
): CatalogPage {
|
||||
CollectionRepository.initialize()
|
||||
val source = CollectionRepository.getCollection(target.collectionId)
|
||||
?.folders
|
||||
?.firstOrNull { it.id == target.folderId }
|
||||
?.resolvedSources
|
||||
?.firstOrNull { it.catalogRouteKey() == target.sourceKey }
|
||||
?: error(getString(Res.string.catalog_load_failed))
|
||||
|
||||
return when {
|
||||
source.isTmdb -> TmdbCollectionSourceResolver.resolve(source = source, page = page)
|
||||
source.isTrakt -> TraktPublicListSourceResolver.resolve(source = source, page = page)
|
||||
else -> error(getString(Res.string.catalog_load_failed))
|
||||
}
|
||||
}
|
||||
|
||||
private data class CatalogRequest(
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val genre: String?,
|
||||
val supportsPagination: Boolean,
|
||||
val target: CatalogTarget,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
||||
|
|
@ -68,11 +68,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun CatalogScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
supportsPagination: Boolean,
|
||||
genre: String? = null,
|
||||
target: CatalogTarget,
|
||||
onBack: () -> Unit,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
|
|
@ -87,19 +83,11 @@ fun CatalogScreen(
|
|||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val initialScrollPosition = remember(
|
||||
manifestUrl,
|
||||
type,
|
||||
catalogId,
|
||||
genre,
|
||||
supportsPagination,
|
||||
target,
|
||||
homeCatalogSettingsUiState.hideUnreleasedContent,
|
||||
) {
|
||||
CatalogRepository.scrollPosition(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
val gridState = rememberLazyGridState(
|
||||
|
|
@ -109,26 +97,18 @@ fun CatalogScreen(
|
|||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
LaunchedEffect(target, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState, manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
LaunchedEffect(gridState, target, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
snapshotFlow { gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
CatalogRepository.saveScrollPosition(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
firstVisibleItemIndex = index,
|
||||
firstVisibleItemScrollOffset = offset,
|
||||
)
|
||||
|
|
@ -148,7 +128,7 @@ fun CatalogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||
LaunchedEffect(networkStatusUiState.condition, target) {
|
||||
when (networkStatusUiState.condition) {
|
||||
NetworkCondition.NoInternet,
|
||||
NetworkCondition.ServersUnreachable,
|
||||
|
|
@ -160,11 +140,7 @@ fun CatalogScreen(
|
|||
if (!observedOfflineState) return@LaunchedEffect
|
||||
observedOfflineState = false
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
|
@ -208,11 +184,7 @@ fun CatalogScreen(
|
|||
onRetry = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
force = true,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface CatalogTarget {
|
||||
val contentType: String
|
||||
val supportsPagination: Boolean
|
||||
|
||||
data class Addon(
|
||||
val manifestUrl: String,
|
||||
override val contentType: String,
|
||||
val catalogId: String,
|
||||
val genre: String? = null,
|
||||
override val supportsPagination: Boolean = false,
|
||||
) : CatalogTarget
|
||||
|
||||
data class Library(
|
||||
override val contentType: String,
|
||||
val sectionType: String,
|
||||
) : CatalogTarget {
|
||||
override val supportsPagination: Boolean = false
|
||||
}
|
||||
|
||||
data class CollectionSource(
|
||||
val collectionId: String,
|
||||
val folderId: String,
|
||||
val sourceKey: String,
|
||||
override val contentType: String,
|
||||
override val supportsPagination: Boolean = false,
|
||||
) : CatalogTarget
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CatalogTargetKind {
|
||||
ADDON,
|
||||
LIBRARY,
|
||||
COLLECTION_SOURCE,
|
||||
}
|
||||
|
|
@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
|
|||
)
|
||||
|
||||
private fun collectionSourceKey(source: CollectionSource): String =
|
||||
when {
|
||||
source.isTmdb -> {
|
||||
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
|
||||
}
|
||||
|
||||
source.isTrakt -> {
|
||||
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
|
||||
}
|
||||
}
|
||||
source.catalogRouteKey()
|
||||
|
||||
private fun selectedMediaTypes(
|
||||
state: CollectionEditorUiState,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ data class CollectionSource(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun CollectionSource.catalogRouteKey(): String =
|
||||
when {
|
||||
isTmdb -> {
|
||||
"tmdb_${tmdbSourceType}_${tmdbId}_${mediaType}_${sortBy}_${filters.hashCode()}"
|
||||
}
|
||||
|
||||
isTrakt -> {
|
||||
"trakt_${traktListId}_${mediaType}_${TraktListSort.normalize(sortBy)}_${TraktSortHow.normalize(sortHow)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"addon_${addonId}_${type}_${catalogId}_${genre.orEmpty()}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
|
||||
isTrakt && (traktListId == null || traktListId <= 0L)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.CatalogTarget
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.nextCatalogPaginationState
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -35,6 +37,7 @@ data class FolderTab(
|
|||
val label: String,
|
||||
val typeLabel: String = "",
|
||||
val source: CollectionSource? = null,
|
||||
val sourceKey: String? = null,
|
||||
val manifestUrl: String? = null,
|
||||
val type: String = "",
|
||||
val catalogId: String = "",
|
||||
|
|
@ -44,6 +47,7 @@ data class FolderTab(
|
|||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
val error: String? = null,
|
||||
val isAllTab: Boolean = false,
|
||||
) {
|
||||
|
|
@ -136,7 +140,7 @@ object FolderDetailRepository {
|
|||
),
|
||||
)
|
||||
}
|
||||
sources.forEach { source ->
|
||||
sources.forEachIndexed { sourceIndex, source ->
|
||||
if (source.isTmdb) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
|
|
@ -145,6 +149,7 @@ object FolderDetailRepository {
|
|||
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
|
||||
typeLabel = "TMDB",
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
type = type,
|
||||
catalogId = tmdbCatalogId(source),
|
||||
supportsPagination = source.tmdbSourceType !in setOf(
|
||||
|
|
@ -172,6 +177,7 @@ object FolderDetailRepository {
|
|||
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
type = type,
|
||||
catalogId = traktCatalogId(source),
|
||||
supportsPagination = true,
|
||||
|
|
@ -179,7 +185,7 @@ object FolderDetailRepository {
|
|||
),
|
||||
)
|
||||
} else {
|
||||
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
||||
val catalogSource = source.addonCatalogSource() ?: return@forEachIndexed
|
||||
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
||||
val addon = resolvedCatalog?.addon
|
||||
val catalog = resolvedCatalog?.catalog
|
||||
|
|
@ -191,6 +197,7 @@ object FolderDetailRepository {
|
|||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = catalogSource.type,
|
||||
catalogId = catalogSource.catalogId,
|
||||
|
|
@ -298,6 +305,7 @@ object FolderDetailRepository {
|
|||
isLoading = true,
|
||||
isLoadingMore = false,
|
||||
nextSkip = null,
|
||||
consecutiveDuplicatePages = 0,
|
||||
error = null,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -340,12 +348,20 @@ object FolderDetailRepository {
|
|||
}
|
||||
val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val loadedNewItems = reset || mergedItems.size > tab.items.size
|
||||
val paginationState = nextCatalogPaginationState(
|
||||
supportsPagination = supportsPagination,
|
||||
requestedSkip = requestedSkip,
|
||||
page = page,
|
||||
loadedNewItems = loadedNewItems,
|
||||
consecutiveDuplicatePages = if (reset) 0 else tab.consecutiveDuplicatePages,
|
||||
)
|
||||
tab.copy(
|
||||
items = mergedItems,
|
||||
supportsPagination = supportsPagination,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
|
||||
nextSkip = paginationState.nextSkip,
|
||||
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -408,20 +424,38 @@ object FolderDetailRepository {
|
|||
fun getCatalogSectionsForRows(): List<HomeCatalogSection> {
|
||||
val current = _uiState.value
|
||||
val folder = current.folder ?: return emptyList()
|
||||
val collectionId = activeCollectionId ?: return emptyList()
|
||||
|
||||
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab ->
|
||||
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.mapNotNull { tab ->
|
||||
val directSource = tab.source?.let { it.isTmdb || it.isTrakt } == true
|
||||
val target = if (directSource) {
|
||||
val sourceKey = tab.sourceKey ?: return@mapNotNull null
|
||||
CatalogTarget.CollectionSource(
|
||||
collectionId = collectionId,
|
||||
folderId = folder.id,
|
||||
sourceKey = sourceKey,
|
||||
contentType = tab.type,
|
||||
supportsPagination = tab.supportsPagination,
|
||||
)
|
||||
} else {
|
||||
val manifestUrl = tab.manifestUrl ?: return@mapNotNull null
|
||||
CatalogTarget.Addon(
|
||||
manifestUrl = manifestUrl,
|
||||
contentType = tab.type,
|
||||
catalogId = tab.catalogId,
|
||||
genre = tab.genre,
|
||||
supportsPagination = tab.supportsPagination,
|
||||
)
|
||||
}
|
||||
HomeCatalogSection(
|
||||
key = "folder_${folder.id}_${tab.label}",
|
||||
title = tab.label,
|
||||
subtitle = tab.typeLabel,
|
||||
addonName = "",
|
||||
type = tab.type,
|
||||
manifestUrl = tab.manifestUrl.orEmpty(),
|
||||
catalogId = tab.catalogId,
|
||||
target = target,
|
||||
items = tab.items,
|
||||
availableItemCount = tab.items.size,
|
||||
supportsPagination = tab.supportsPagination,
|
||||
genre = tab.genre,
|
||||
hasMore = tab.canLoadMore,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioPosterCard
|
||||
import com.nuvio.app.core.ui.NuvioPosterShape
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.nuvio.app.features.streams.StreamItem
|
|||
internal object DebridMagnetBuilder {
|
||||
fun fromStream(stream: StreamItem): String? {
|
||||
stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hash = stream.p2pInfoHash ?: return null
|
||||
return buildString {
|
||||
append("magnet:?xt=urn:btih:")
|
||||
append(hash)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.details
|
|||
import com.nuvio.app.features.streams.StreamBehaviorHints
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamProxyHeaders
|
||||
import com.nuvio.app.features.streams.normalizeStreamType
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
|
@ -288,6 +289,7 @@ internal object MetaDetailsParser {
|
|||
externalUrl = externalUrl,
|
||||
addonName = addonName,
|
||||
addonId = "embedded",
|
||||
streamType = normalizeStreamType(obj.string("type")),
|
||||
behaviorHints = StreamBehaviorHints(
|
||||
bingeGroup = hintsObj?.string("bingeGroup"),
|
||||
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.build.TrailerPlaybackMode
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
|
|
@ -88,7 +88,6 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
|
|
@ -460,30 +459,6 @@ fun MetaDetailsScreen(
|
|||
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
|
||||
}
|
||||
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
|
||||
val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) {
|
||||
if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) {
|
||||
DetailDebridWarmupTarget(
|
||||
videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id,
|
||||
season = seriesAction?.seasonNumber,
|
||||
episode = seriesAction?.episodeNumber,
|
||||
)
|
||||
} else {
|
||||
DetailDebridWarmupTarget(
|
||||
videoId = meta.id,
|
||||
season = null,
|
||||
episode = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) {
|
||||
if (!deferredMetaWorkAllowed) return@LaunchedEffect
|
||||
AddonStreamWarmupRepository.preload(
|
||||
type = meta.type,
|
||||
videoId = debridWarmupTarget.videoId,
|
||||
season = debridWarmupTarget.season,
|
||||
episode = debridWarmupTarget.episode,
|
||||
)
|
||||
}
|
||||
val hasProductionSection = remember(meta) {
|
||||
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
|
||||
}
|
||||
|
|
@ -781,7 +756,6 @@ fun MetaDetailsScreen(
|
|||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val isTablet = maxWidth >= 720.dp
|
||||
val viewportHeight = maxHeight
|
||||
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
|
||||
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
|
||||
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed
|
||||
|
|
@ -816,7 +790,6 @@ fun MetaDetailsScreen(
|
|||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
scrollOffset = heroScrollOffset,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
heroTrailerSourceUrl = heroTrailerSourceUrl,
|
||||
|
|
@ -1483,6 +1456,7 @@ private fun DetailSectionContainer(
|
|||
Modifier.widthIn(max = contentMaxWidth)
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
|
@ -1820,14 +1794,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
|
|||
} else {
|
||||
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
|
||||
}
|
||||
|
||||
private data class DetailDebridWarmupTarget(
|
||||
val videoId: String,
|
||||
val season: Int?,
|
||||
val episode: Int?,
|
||||
)
|
||||
|
||||
private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean =
|
||||
hasEpisodes || type.equals("series", ignoreCase = true) ||
|
||||
type.equals("show", ignoreCase = true) ||
|
||||
type.equals("tv", ignoreCase = true)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.core.i18n.localizedShortMonthName
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.AppIconResource
|
||||
import com.nuvio.app.core.ui.appIconPainter
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_play
|
||||
import nuvio.composeapp.generated.resources.details_actions_menu_label
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
data class DetailSecondaryAction(
|
||||
|
|
@ -59,7 +59,7 @@ fun DetailActionButtons(
|
|||
modifier: Modifier = Modifier,
|
||||
playLabel: String = stringResource(Res.string.action_play),
|
||||
secondaryActions: List<DetailSecondaryAction> = emptyList(),
|
||||
actionsMenuLabel: String = "More actions",
|
||||
actionsMenuLabel: String = stringResource(Res.string.details_actions_menu_label),
|
||||
isTablet: Boolean = false,
|
||||
onPlayClick: () -> Unit = {},
|
||||
onPlayLongClick: (() -> Unit)? = null,
|
||||
|
|
@ -108,7 +108,6 @@ fun DetailActionButtons(
|
|||
onLongClick = onPlayLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.secondaryClick(onPlayLongClick)
|
||||
.height(buttonHeight),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -251,8 +250,7 @@ private fun DetailIconAction(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.isIos
|
||||
|
|
@ -111,7 +111,7 @@ fun DetailFloatingHeader(
|
|||
.padding(horizontal = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (meta.logo != null && !logoLoadError) {
|
||||
if (!meta.logo.isNullOrBlank() && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -43,8 +45,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -55,7 +56,6 @@ fun DetailHero(
|
|||
isTablet: Boolean = false,
|
||||
scrollOffset: Int = 0,
|
||||
contentMaxWidth: Dp = 560.dp,
|
||||
viewportHeight: Dp = 0.dp,
|
||||
onHeightChanged: (Int) -> Unit = {},
|
||||
heroTrailerSourceUrl: String? = null,
|
||||
heroTrailerSourceAudioUrl: String? = null,
|
||||
|
|
@ -71,7 +71,7 @@ fun DetailHero(
|
|||
BoxWithConstraints(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val heroHeight = detailHeroHeight(maxWidth, viewportHeight, isTablet)
|
||||
val heroHeight = detailHeroHeight(maxWidth, isTablet)
|
||||
val trailerAlpha by animateFloatAsState(
|
||||
targetValue = if (heroTrailerReady) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
|
|
@ -81,6 +81,10 @@ fun DetailHero(
|
|||
val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +
|
||||
8.dp +
|
||||
((40.dp - muteIconSize) / 2)
|
||||
var logoLoadError by remember(meta.id, meta.logo) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val logoUrl = meta.logo?.takeIf { it.isNotBlank() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -97,7 +101,6 @@ fun DetailHero(
|
|||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
val imageUrl = meta.background ?: meta.poster
|
||||
val backdropScale = if (isTablet) 1f else 1.08f
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
|
|
@ -106,12 +109,11 @@ fun DetailHero(
|
|||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationY = scrollOffset * 0.5f
|
||||
scaleX = backdropScale
|
||||
scaleY = backdropScale
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
},
|
||||
alignment = Alignment.Center,
|
||||
alignment = if (isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
|
|
@ -131,8 +133,8 @@ fun DetailHero(
|
|||
.graphicsLayer {
|
||||
alpha = trailerAlpha
|
||||
translationY = scrollOffset * 0.5f
|
||||
scaleX = backdropScale
|
||||
scaleY = backdropScale
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
},
|
||||
onReady = onHeroTrailerReady,
|
||||
onEnded = onHeroTrailerEnded,
|
||||
|
|
@ -202,9 +204,9 @@ fun DetailHero(
|
|||
.padding(bottom = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (meta.logo != null) {
|
||||
if (logoUrl != null && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
model = logoUrl,
|
||||
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
|
||||
|
|
@ -212,6 +214,7 @@ fun DetailHero(
|
|||
.height(if (isTablet) 72.dp else 80.dp),
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Fit,
|
||||
onError = { logoLoadError = true },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
|
|
@ -237,13 +240,9 @@ fun DetailHero(
|
|||
}
|
||||
}
|
||||
|
||||
private fun detailHeroHeight(maxWidth: Dp, viewportHeight: Dp, isTablet: Boolean): Dp =
|
||||
private fun detailHeroHeight(maxWidth: Dp, isTablet: Boolean): Dp =
|
||||
if (!isTablet) {
|
||||
(maxWidth * 1.33f).coerceIn(420.dp, 760.dp)
|
||||
} else {
|
||||
val viewportLimit = viewportHeight
|
||||
.takeIf { it > 0.dp }
|
||||
?.let { it * 0.72f }
|
||||
?: 1080.dp
|
||||
minOf(maxWidth * 9f / 16f, viewportLimit).coerceIn(420.dp, 1080.dp)
|
||||
(maxWidth * 0.42f).coerceIn(300.dp, 420.dp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaCompany
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
|
|
|
|||
|
|
@ -58,14 +58,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaEpisodeCardStyle
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
|
|
@ -409,7 +408,6 @@ private fun SeasonTextChipScrollRow(
|
|||
) {
|
||||
items(seasons, key = { season -> season }) { season ->
|
||||
val isSelected = season == currentSeason
|
||||
val onSecondaryClick = onLongPress?.let { handler -> { handler(season) } }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(sizing.seasonChipRadius))
|
||||
|
|
@ -422,9 +420,8 @@ private fun SeasonTextChipScrollRow(
|
|||
)
|
||||
.combinedClickable(
|
||||
onClick = { onSelect(season) },
|
||||
onLongClick = onSecondaryClick,
|
||||
onLongClick = onLongPress?.let { handler -> { handler(season) } },
|
||||
)
|
||||
.secondaryClick(onSecondaryClick)
|
||||
.padding(
|
||||
horizontal = sizing.seasonChipHorizontalPadding,
|
||||
vertical = sizing.seasonChipVerticalPadding,
|
||||
|
|
@ -511,8 +508,7 @@ private fun SeasonPosterButton(
|
|||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -681,8 +677,7 @@ private fun EpisodeHorizontalCard(
|
|||
enabled = onClick != null || onLongPress != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongPress,
|
||||
)
|
||||
.secondaryClick(onLongPress),
|
||||
),
|
||||
) {
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
|
|
@ -1050,8 +1045,7 @@ private fun EpisodeListCard(
|
|||
enabled = onClick != null || onLongPress != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongPress,
|
||||
)
|
||||
.secondaryClick(onLongPress),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaTrailer
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.detail_tab_trailer
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogTarget
|
||||
|
||||
data class MetaPreview(
|
||||
val id: String,
|
||||
|
|
@ -33,17 +33,14 @@ data class HomeCatalogSection(
|
|||
val title: String,
|
||||
val subtitle: String,
|
||||
val addonName: String,
|
||||
val type: String,
|
||||
val manifestUrl: String,
|
||||
val catalogId: String,
|
||||
val target: CatalogTarget,
|
||||
val items: List<MetaPreview>,
|
||||
val availableItemCount: Int = items.size,
|
||||
val supportsPagination: Boolean = false,
|
||||
val genre: String? = null,
|
||||
val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
|
||||
availableItemCount > previewLimit || (supportsPagination && availableItemCount >= CATALOG_PAGE_SIZE)
|
||||
availableItemCount > previewLimit || hasMore
|
||||
|
||||
data class HomeUiState(
|
||||
val isLoading: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ package com.nuvio.app.features.home
|
|||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.catalog.CatalogTarget
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.collection.Collection
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.collection.CollectionSource
|
||||
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
|
||||
import com.nuvio.app.features.collection.catalogRouteKey
|
||||
import com.nuvio.app.features.collection.findCollectionCatalog
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
|
|
@ -238,12 +240,15 @@ object HomeRepository {
|
|||
title = defaultTitle,
|
||||
subtitle = addonName,
|
||||
addonName = addonName,
|
||||
type = type,
|
||||
manifestUrl = manifestUrl,
|
||||
catalogId = catalogId,
|
||||
target = CatalogTarget.Addon(
|
||||
manifestUrl = manifestUrl,
|
||||
contentType = type,
|
||||
catalogId = catalogId,
|
||||
supportsPagination = supportsPagination,
|
||||
),
|
||||
items = emptyList(),
|
||||
availableItemCount = 0,
|
||||
supportsPagination = supportsPagination,
|
||||
hasMore = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -252,12 +257,15 @@ object HomeRepository {
|
|||
title = defaultTitle,
|
||||
subtitle = addonName,
|
||||
addonName = addonName,
|
||||
type = type,
|
||||
manifestUrl = manifestUrl,
|
||||
catalogId = catalogId,
|
||||
target = CatalogTarget.Addon(
|
||||
manifestUrl = manifestUrl,
|
||||
contentType = type,
|
||||
catalogId = catalogId,
|
||||
supportsPagination = supportsPagination,
|
||||
),
|
||||
items = items,
|
||||
availableItemCount = page.rawItemCount,
|
||||
supportsPagination = supportsPagination,
|
||||
hasMore = supportsPagination && page.nextSkip != null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -411,19 +419,7 @@ object HomeRepository {
|
|||
}
|
||||
|
||||
private fun collectionSourceKey(source: CollectionSource): String =
|
||||
listOf(
|
||||
source.provider,
|
||||
source.addonId,
|
||||
source.type,
|
||||
source.catalogId,
|
||||
source.genre,
|
||||
source.tmdbSourceType,
|
||||
source.tmdbId?.toString(),
|
||||
source.traktListId?.toString(),
|
||||
source.mediaType,
|
||||
source.sortBy,
|
||||
source.sortHow,
|
||||
).joinToString(":") { it.orEmpty() }
|
||||
source.catalogRouteKey()
|
||||
}
|
||||
|
||||
private const val HOME_HERO_ITEM_LIMIT = 8
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.network.NetworkCondition
|
||||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
||||
|
|
@ -105,21 +104,19 @@ fun HomeScreen(
|
|||
onFirstCatalogRendered: (() -> Unit)? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.Default) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
}
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
}
|
||||
|
||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val homeSettingsUiState by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val homeListState = rememberLazyListState()
|
||||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -127,8 +124,14 @@ fun HomeScreen(
|
|||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by TraktSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by TraktAuthRepository.isAuthenticated.collectAsStateWithLifecycle()
|
||||
val traktSettingsUiState by remember {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val isTraktAuthenticated by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.isAuthenticated
|
||||
}.collectAsStateWithLifecycle()
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scrollToTopRequests) {
|
||||
|
|
@ -278,11 +281,20 @@ fun HomeScreen(
|
|||
}
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
|
||||
val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle()
|
||||
|
||||
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
|
||||
LaunchedEffect(activeProfileId, cwCacheClearVersion) {
|
||||
if (cwCacheClearVersion == 0) return@LaunchedEffect
|
||||
nextUpItemsBySeries = emptyMap()
|
||||
processedNextUpContentIds = emptySet()
|
||||
}
|
||||
|
||||
val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) {
|
||||
ContinueWatchingEnrichmentCache.getSnapshots()
|
||||
}
|
||||
val shouldValidateMissingNextUpSeeds = remember(
|
||||
isTraktProgressActive,
|
||||
watchProgressUiState.hasLoadedRemoteProgress,
|
||||
|
|
@ -647,7 +659,6 @@ fun HomeScreen(
|
|||
modifier = Modifier,
|
||||
viewportHeight = maxHeight,
|
||||
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
|
||||
sectionPadding = if (isDesktop) homeSectionPadding else null,
|
||||
)
|
||||
|
||||
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
|
||||
|
|
@ -655,7 +666,6 @@ fun HomeScreen(
|
|||
modifier = Modifier,
|
||||
viewportHeight = maxHeight,
|
||||
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
|
||||
sectionPadding = if (isDesktop) homeSectionPadding else null,
|
||||
listState = homeListState,
|
||||
onItemClick = onPosterClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import androidx.compose.ui.unit.TextUnit
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
|
|
@ -53,7 +53,6 @@ import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
|||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -804,8 +803,7 @@ private fun ContinueWatchingWideCard(
|
|||
enabled = onClick != null || onLongClick != null,
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.secondaryClick(onLongClick),
|
||||
),
|
||||
) {
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -31,8 +30,10 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -48,9 +49,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.NuvioDesktopImageScaling
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -89,7 +88,6 @@ fun HomeHeroSection(
|
|||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
listState: LazyListState? = null,
|
||||
onItemClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -112,7 +110,6 @@ fun HomeHeroSection(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
|
||||
val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() }
|
||||
|
|
@ -168,32 +165,136 @@ fun HomeHeroSection(
|
|||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
DesktopHomeHeroFrame(
|
||||
items = items,
|
||||
visiblePages = visiblePages,
|
||||
layout = layout,
|
||||
heroWidthPx = heroWidthPx,
|
||||
heroScrollScale = heroScrollScale,
|
||||
heroScrollTranslationY = heroScrollTranslationY,
|
||||
contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
onItemClick = onItemClick,
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.02f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.12f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
DefaultHomeHeroFrame(
|
||||
items = items,
|
||||
visiblePages = visiblePages,
|
||||
currentItem = currentItem,
|
||||
layout = layout,
|
||||
heroWidthPx = heroWidthPx,
|
||||
heroScrollScale = heroScrollScale,
|
||||
heroScrollTranslationY = heroScrollTranslationY,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
onItemClick = onItemClick,
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0f),
|
||||
MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
HeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout.isTablet) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(currentItem)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.home_view_details),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.size > 1) {
|
||||
Spacer(modifier = Modifier.height(if (layout.isTablet) 14.dp else 12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items.forEachIndexed { index, _ ->
|
||||
val activeFraction = heroPageVisibility(pagerState, index)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.graphicsLayer {
|
||||
alpha = 0.35f + (0.57f * activeFraction)
|
||||
}
|
||||
.width(8.dp + (24.dp * activeFraction))
|
||||
.height(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -205,284 +306,6 @@ private data class HeroPageLayer(
|
|||
val offset: Float,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun DefaultHomeHeroFrame(
|
||||
items: List<MetaPreview>,
|
||||
visiblePages: List<HeroPageLayer>,
|
||||
currentItem: MetaPreview,
|
||||
layout: HomeHeroLayout,
|
||||
heroWidthPx: Float,
|
||||
heroScrollScale: Float,
|
||||
heroScrollTranslationY: Float,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.02f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.12f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.34f),
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0.78f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.background.copy(alpha = 0f),
|
||||
MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
HeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout.isTablet) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(currentItem)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
contentColor = MaterialTheme.colorScheme.background,
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.home_view_details),
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HeroPageIndicatorRow(
|
||||
itemCount = items.size,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
modifier = Modifier.padding(top = if (layout.isTablet) 14.dp else 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHomeHeroFrame(
|
||||
items: List<MetaPreview>,
|
||||
visiblePages: List<HeroPageLayer>,
|
||||
layout: HomeHeroLayout,
|
||||
heroWidthPx: Float,
|
||||
heroScrollScale: Float,
|
||||
heroScrollTranslationY: Float,
|
||||
contentHorizontalPadding: Dp,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(0.64f)
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = 1.02f * heroScrollScale
|
||||
scaleY = 1.02f * heroScrollScale
|
||||
},
|
||||
) {
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
alignment = Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
desktopImageScaling = NuvioDesktopImageScaling.Disabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to backgroundColor,
|
||||
0.34f to backgroundColor,
|
||||
0.58f to backgroundColor.copy(alpha = 0.78f),
|
||||
0.78f to backgroundColor.copy(alpha = 0.18f),
|
||||
1f to backgroundColor.copy(alpha = 0f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.bottomFadeHeight)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.copy(alpha = 0f),
|
||||
backgroundColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = contentHorizontalPadding, end = contentHorizontalPadding)
|
||||
.fillMaxWidth(layout.contentWidthFraction)
|
||||
.widthIn(max = layout.contentMaxWidth),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
|
||||
},
|
||||
) {
|
||||
DesktopHeroContentBlock(
|
||||
item = items[layer.page],
|
||||
layout = layout,
|
||||
onItemClick = onItemClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HeroPageIndicatorRow(
|
||||
itemCount = items.size,
|
||||
pagerState = pagerState,
|
||||
coroutineScope = coroutineScope,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(
|
||||
start = contentHorizontalPadding,
|
||||
bottom = layout.contentVerticalPadding,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroPageIndicatorRow(
|
||||
itemCount: Int,
|
||||
pagerState: PagerState,
|
||||
coroutineScope: CoroutineScope,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (itemCount <= 1) return
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(itemCount) { index ->
|
||||
val activeFraction = heroPageVisibility(pagerState, index)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onBackground)
|
||||
.graphicsLayer {
|
||||
alpha = 0.35f + (0.57f * activeFraction)
|
||||
}
|
||||
.width(8.dp + (24.dp * activeFraction))
|
||||
.height(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun heroPageOffset(
|
||||
pagerState: PagerState,
|
||||
page: Int,
|
||||
|
|
@ -510,7 +333,6 @@ fun HomeHeroReservedSpace(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
|
||||
Spacer(
|
||||
|
|
@ -527,13 +349,18 @@ private fun HeroContentBlock(
|
|||
layout: HomeHeroLayout,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
var logoLoadError by remember(item.type, item.id, item.logo) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val logoUrl = item.logo?.takeIf { it.isNotBlank() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (item.logo != null) {
|
||||
if (logoUrl != null && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = item.logo,
|
||||
model = logoUrl,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(layout.logoWidthFraction)
|
||||
|
|
@ -543,6 +370,7 @@ private fun HeroContentBlock(
|
|||
},
|
||||
alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
|
||||
contentScale = ContentScale.Fit,
|
||||
onError = { logoLoadError = true },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
|
|
@ -588,97 +416,6 @@ private fun HeroContentBlock(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHeroContentBlock(
|
||||
item: MetaPreview,
|
||||
layout: HomeHeroLayout,
|
||||
onItemClick: ((MetaPreview) -> Unit)?,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = onItemClick != null) {
|
||||
onItemClick?.invoke(item)
|
||||
},
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
if (item.logo != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(desktopHeroLogoSlotHeight(layout)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.logo,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(desktopHeroLogoWidthFraction(layout))
|
||||
.fillMaxHeight(),
|
||||
alignment = Alignment.CenterStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
clipToBounds = false,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Black,
|
||||
textAlign = TextAlign.Start,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
val genreText = desktopHeroGenreText(item)
|
||||
if (genreText.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = genreText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.76f),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
item.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.82f),
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun desktopHeroLogoWidthFraction(layout: HomeHeroLayout): Float =
|
||||
when {
|
||||
layout.contentMaxWidth >= 640.dp -> 0.74f
|
||||
layout.contentMaxWidth >= 520.dp -> 0.74f
|
||||
else -> 0.8f
|
||||
}
|
||||
|
||||
private fun desktopHeroLogoSlotHeight(layout: HomeHeroLayout): Dp =
|
||||
when {
|
||||
layout.contentMaxWidth >= 640.dp -> 156.dp
|
||||
layout.contentMaxWidth >= 520.dp -> 136.dp
|
||||
else -> 104.dp
|
||||
}
|
||||
|
||||
private fun desktopHeroGenreText(item: MetaPreview): String =
|
||||
item.genres
|
||||
.take(3)
|
||||
.joinToString(" • ")
|
||||
.ifBlank { item.type.replaceFirstChar(Char::uppercase) }
|
||||
|
||||
@Composable
|
||||
private fun HeroMetaText(text: String) {
|
||||
Text(
|
||||
|
|
@ -695,7 +432,6 @@ internal fun homeHeroLayout(
|
|||
maxWidthDp: Float,
|
||||
viewportHeightDp: Float? = null,
|
||||
mobileBelowSectionHeightHintDp: Float? = null,
|
||||
preferDesktopLayout: Boolean = false,
|
||||
): HomeHeroLayout =
|
||||
when {
|
||||
maxWidthDp >= 1200f -> HomeHeroLayout(
|
||||
|
|
@ -728,16 +464,6 @@ internal fun homeHeroLayout(
|
|||
bottomFadeHeight = 170.dp,
|
||||
logoWidthFraction = 0.54f,
|
||||
)
|
||||
preferDesktopLayout -> HomeHeroLayout(
|
||||
isTablet = true,
|
||||
heroHeight = (maxWidthDp * 0.68f).dp.coerceIn(300.dp, 360.dp),
|
||||
contentMaxWidth = 360.dp,
|
||||
contentWidthFraction = 0.56f,
|
||||
contentHorizontalPadding = 16.dp,
|
||||
contentVerticalPadding = 18.dp,
|
||||
bottomFadeHeight = 150.dp,
|
||||
logoWidthFraction = 0.64f,
|
||||
)
|
||||
else -> HomeHeroLayout(
|
||||
isTablet = false,
|
||||
heroHeight = mobileHeroHeight(
|
||||
|
|
@ -871,9 +597,10 @@ private fun resolveHeroTargetPage(
|
|||
abs(velocityX) > HERO_SWIPE_VELOCITY_THRESHOLD
|
||||
if (!thresholdPassed) return startPage
|
||||
|
||||
val currentPage = startPage.coerceIn(0, itemCount - 1)
|
||||
return when {
|
||||
totalDx > 0f -> (startPage - 1).coerceAtLeast(0)
|
||||
totalDx < 0f -> (startPage + 1).coerceAtMost(itemCount - 1)
|
||||
else -> startPage
|
||||
totalDx > 0f -> if (currentPage == 0) itemCount - 1 else currentPage - 1
|
||||
totalDx < 0f -> if (currentPage == itemCount - 1) 0 else currentPage + 1
|
||||
else -> currentPage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import androidx.compose.ui.geometry.Offset
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
|
|
@ -66,7 +65,6 @@ fun HomeSkeletonHero(
|
|||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
) {
|
||||
val brush = rememberHomeSkeletonBrush()
|
||||
|
||||
|
|
@ -79,10 +77,8 @@ fun HomeSkeletonHero(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
val containerWidth = maxWidth
|
||||
val contentHorizontalPadding = sectionPadding ?: layout.contentHorizontalPadding
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -125,7 +121,7 @@ fun HomeSkeletonHero(
|
|||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = contentHorizontalPadding,
|
||||
horizontal = layout.contentHorizontalPadding,
|
||||
vertical = layout.contentVerticalPadding,
|
||||
),
|
||||
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.library
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.ui.NuvioToastController
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
|
|
@ -40,6 +41,7 @@ import kotlinx.serialization.json.put
|
|||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.library_local_tab_title
|
||||
import nuvio.composeapp.generated.resources.library_other
|
||||
import nuvio.composeapp.generated.resources.trakt_lists_update_failed
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
|
|
@ -218,7 +220,13 @@ object LibraryRepository {
|
|||
if (isTraktLibrarySourceActive()) {
|
||||
syncScope.launch {
|
||||
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
|
||||
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
|
||||
.onFailure { e ->
|
||||
log.e(e) { "Failed to toggle Trakt watchlist" }
|
||||
NuvioToastController.show(
|
||||
e.message?.takeIf { it.isNotBlank() }
|
||||
?: getString(Res.string.trakt_lists_update_failed),
|
||||
)
|
||||
}
|
||||
publish()
|
||||
}
|
||||
return
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||
@Composable
|
||||
fun LibraryScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
scrollToTopRequests: Flow<Unit> = emptyFlow(),
|
||||
onPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||
|
|
@ -175,7 +174,6 @@ fun LibraryScreen(
|
|||
NuvioScreen(
|
||||
modifier = modifier,
|
||||
horizontalPadding = 0.dp,
|
||||
topPadding = if (topChromePadding != null) 0.dp else null,
|
||||
listState = listState,
|
||||
) {
|
||||
stickyHeader {
|
||||
|
|
@ -198,7 +196,6 @@ fun LibraryScreen(
|
|||
stringResource(Res.string.library_title)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topPadding = topChromePadding,
|
||||
)
|
||||
LibrarySourceSwitch(
|
||||
selectedMode = sourceMode,
|
||||
|
|
|
|||
|
|
@ -23,233 +23,6 @@ interface PlayerEngineController {
|
|||
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
|
||||
}
|
||||
|
||||
enum class PlayerControlsAction {
|
||||
ToggleChrome,
|
||||
RevealLockedOverlay,
|
||||
Back,
|
||||
TogglePlayback,
|
||||
KeyboardTogglePlayback,
|
||||
SeekBack,
|
||||
KeyboardSeekBack,
|
||||
SeekForward,
|
||||
KeyboardSeekForward,
|
||||
ResizeMode,
|
||||
Speed,
|
||||
Subtitles,
|
||||
Audio,
|
||||
Sources,
|
||||
Episodes,
|
||||
OpenExternalPlayer,
|
||||
SubmitIntro,
|
||||
LockToggle,
|
||||
VideoSettings,
|
||||
DoubleTapSeekBack,
|
||||
DoubleTapSeekForward,
|
||||
}
|
||||
|
||||
data class PlayerControlsState(
|
||||
val title: String = "",
|
||||
val episodeText: String = "",
|
||||
val streamTitle: String = "",
|
||||
val providerName: String = "",
|
||||
val pauseOverlayWatchingLabel: String = "You're watching",
|
||||
val pauseOverlayLogo: String? = null,
|
||||
val pauseOverlayEpisodeInfo: String = "",
|
||||
val pauseOverlayEpisodeTitle: String = "",
|
||||
val pauseOverlayDescription: String = "",
|
||||
val resizeModeLabel: String = "Fit",
|
||||
val playbackSpeedLabel: String = "1x",
|
||||
val subtitlesLabel: String = "Subs",
|
||||
val audioLabel: String = "Audio",
|
||||
val sourcesLabel: String = "Sources",
|
||||
val episodesLabel: String = "Episodes",
|
||||
val externalPlayerLabel: String = "External",
|
||||
val playLabel: String = "Play",
|
||||
val pauseLabel: String = "Pause",
|
||||
val closeLabel: String = "Close player",
|
||||
val lockLabel: String = "Lock player controls",
|
||||
val unlockLabel: String = "Unlock player controls",
|
||||
val submitIntroLabel: String = "Submit Intro",
|
||||
val videoSettingsLabel: String = "Video settings",
|
||||
val tapToUnlockLabel: String = "Tap to unlock",
|
||||
val playbackErrorTitle: String = "Playback error",
|
||||
val playbackErrorMessage: String = "",
|
||||
val playbackErrorActionLabel: String = "Go back",
|
||||
val sourcesPanelTitle: String = "Sources",
|
||||
val episodesPanelTitle: String = "Episodes",
|
||||
val streamsPanelTitle: String = "Streams",
|
||||
val allFilterLabel: String = "All",
|
||||
val reloadLabel: String = "Reload",
|
||||
val backLabel: String = "Back",
|
||||
val panelCloseLabel: String = "Close",
|
||||
val cancelLabel: String = "Cancel",
|
||||
val playingLabel: String = "Playing",
|
||||
val noStreamsLabel: String = "No streams found",
|
||||
val noEpisodesLabel: String = "No episodes available",
|
||||
val submitIntroPanelTitle: String = "Submit Timestamps",
|
||||
val submitIntroSegmentTypeLabel: String = "SEGMENT TYPE",
|
||||
val submitIntroSegmentIntroLabel: String = "Intro",
|
||||
val submitIntroSegmentRecapLabel: String = "Recap",
|
||||
val submitIntroSegmentOutroLabel: String = "Outro",
|
||||
val submitIntroStartTimeLabel: String = "START TIME (MM:SS)",
|
||||
val submitIntroEndTimeLabel: String = "END TIME (MM:SS)",
|
||||
val submitIntroCaptureLabel: String = "Capture",
|
||||
val submitIntroSubmitLabel: String = "Submit",
|
||||
val p2pConsentTitle: String = "P2P Streaming",
|
||||
val p2pConsentBody: String = "",
|
||||
val p2pConsentEnableLabel: String = "Enable P2P",
|
||||
val p2pConsentCancelLabel: String = "Cancel",
|
||||
val subtitlesPanelTitle: String = "Subtitles",
|
||||
val subtitleBuiltInTabLabel: String = "Built-in",
|
||||
val subtitleAddonsTabLabel: String = "Addons",
|
||||
val subtitleStyleTabLabel: String = "Style",
|
||||
val noneLabel: String = "None",
|
||||
val fetchSubtitlesLabel: String = "Tap to fetch subtitles",
|
||||
val subtitleDelayLabel: String = "Subtitle Delay",
|
||||
val resetLabel: String = "Reset",
|
||||
val autoSyncLabel: String = "Auto Sync",
|
||||
val reloadSmallLabel: String = "Reload",
|
||||
val captureLineLabel: String = "Capture",
|
||||
val selectAddonSubtitleFirstLabel: String = "Select an addon subtitle first",
|
||||
val loadingSubtitleLinesLabel: String = "Loading subtitle lines...",
|
||||
val fontSizeLabel: String = "Font Size",
|
||||
val outlineLabel: String = "Outline",
|
||||
val boldLabel: String = "Bold",
|
||||
val bottomOffsetLabel: String = "Bottom Offset",
|
||||
val colorLabel: String = "Color",
|
||||
val textOpacityLabel: String = "Text Opacity",
|
||||
val outlineColorLabel: String = "Outline Color",
|
||||
val resetDefaultsLabel: String = "Reset Defaults",
|
||||
val onLabel: String = "On",
|
||||
val offLabel: String = "Off",
|
||||
val themeAccentColor: String = "#2f6fed",
|
||||
val themeAccentStrongColor: String = "#3c7bff",
|
||||
val themeOnAccentColor: String = "#ffffff",
|
||||
val themeFocusColor: String = "#9ecaff",
|
||||
val themeSelectedSurfaceColor: String = "#26384f",
|
||||
val themeSelectedSurfaceHoverColor: String = "#2d4565",
|
||||
val themeSelectedRingColor: String = "rgba(47, 111, 237, .35)",
|
||||
val themeTimelineFillColor: String = "#ffffff",
|
||||
val themeTimelineTrackColor: String = "rgba(255, 255, 255, .28)",
|
||||
val themeBufferingColor: String = "#ffffff",
|
||||
val themeBufferingTrackColor: String = "rgba(255, 255, 255, .28)",
|
||||
val themeControlForegroundColor: String = "#ffffff",
|
||||
val isPlaying: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isLocked: Boolean = false,
|
||||
val lockedOverlayVisible: Boolean = false,
|
||||
val controlsVisible: Boolean = true,
|
||||
val parentalWarnings: List<ParentalWarning> = emptyList(),
|
||||
val showParentalGuide: Boolean = false,
|
||||
val showOpeningOverlay: Boolean = false,
|
||||
val openingArtwork: String? = null,
|
||||
val openingLogo: String? = null,
|
||||
val openingTitle: String = "",
|
||||
val openingMessage: String? = null,
|
||||
val openingProgress: Float? = null,
|
||||
val skipPromptVisible: Boolean = false,
|
||||
val skipPromptLabel: String = "Skip",
|
||||
val skipPromptStartMs: Long = 0L,
|
||||
val skipPromptEndMs: Long = 0L,
|
||||
val skipPromptDismissed: Boolean = false,
|
||||
val nextEpisodeVisible: Boolean = false,
|
||||
val nextEpisodeHeaderLabel: String = "Next episode",
|
||||
val nextEpisodeTitle: String = "",
|
||||
val nextEpisodeThumbnail: String = "",
|
||||
val nextEpisodeStatus: String = "",
|
||||
val nextEpisodeActionLabel: String = "Play",
|
||||
val nextEpisodePlayable: Boolean = false,
|
||||
val showSubmitIntro: Boolean = false,
|
||||
val showVideoSettings: Boolean = false,
|
||||
val showSources: Boolean = false,
|
||||
val showEpisodes: Boolean = false,
|
||||
val showExternalPlayer: Boolean = false,
|
||||
val durationMs: Long = 0L,
|
||||
val positionMs: Long = 0L,
|
||||
val sourceIsLoading: Boolean = false,
|
||||
val sourceFilters: List<PlayerControlFilterItem> = emptyList(),
|
||||
val sourceItems: List<PlayerControlSourceItem> = emptyList(),
|
||||
val episodeItems: List<PlayerControlEpisodeItem> = emptyList(),
|
||||
val episodeSeasons: List<PlayerControlSeasonItem> = emptyList(),
|
||||
val episodeStreamsVisible: Boolean = false,
|
||||
val episodeStreamsIsLoading: Boolean = false,
|
||||
val selectedEpisodeLabel: String = "",
|
||||
val episodeStreamFilters: List<PlayerControlFilterItem> = emptyList(),
|
||||
val episodeStreamItems: List<PlayerControlSourceItem> = emptyList(),
|
||||
val submitIntroSegmentType: String = "intro",
|
||||
val submitIntroStartTime: String = "00:00",
|
||||
val submitIntroEndTime: String = "00:00",
|
||||
val isSubmitIntroSubmitting: Boolean = false,
|
||||
val submitIntroStatusMessage: String = "",
|
||||
val showP2pConsent: Boolean = false,
|
||||
val subtitleActiveTab: String = "BuiltIn",
|
||||
val addonSubtitleItems: List<PlayerControlAddonSubtitleItem> = emptyList(),
|
||||
val isLoadingAddonSubtitles: Boolean = false,
|
||||
val selectedAddonSubtitleId: String = "",
|
||||
val useCustomSubtitles: Boolean = false,
|
||||
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
|
||||
val subtitleDelayMs: Int = 0,
|
||||
val hasSelectedAddonSubtitle: Boolean = false,
|
||||
val subtitleAutoSyncCapturedPositionMs: Long = -1L,
|
||||
val subtitleAutoSyncCues: List<PlayerControlSubtitleCueItem> = emptyList(),
|
||||
val subtitleAutoSyncIsLoading: Boolean = false,
|
||||
val subtitleAutoSyncErrorMessage: String = "",
|
||||
val closeModalsToken: Long = 0L,
|
||||
)
|
||||
|
||||
data class PlayerControlFilterItem(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val hasError: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSeasonItem(
|
||||
val season: Int = 0,
|
||||
val label: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSourceItem(
|
||||
val index: Int = 0,
|
||||
val filterId: String = "",
|
||||
val label: String = "",
|
||||
val subtitle: String = "",
|
||||
val addonName: String = "",
|
||||
val isCurrent: Boolean = false,
|
||||
val isEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
data class PlayerControlEpisodeItem(
|
||||
val index: Int = 0,
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
val code: String = "",
|
||||
val overview: String = "",
|
||||
val thumbnail: String = "",
|
||||
val season: Int = 0,
|
||||
val episode: Int = 0,
|
||||
val isCurrent: Boolean = false,
|
||||
val isWatched: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlAddonSubtitleItem(
|
||||
val index: Int = 0,
|
||||
val id: String = "",
|
||||
val display: String = "",
|
||||
val languageLabel: String = "",
|
||||
val addonName: String = "",
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
data class PlayerControlSubtitleCueItem(
|
||||
val index: Int = 0,
|
||||
val timeMs: Long = 0L,
|
||||
val timeLabel: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
internal fun sanitizePlaybackHeaders(headers: Map<String, String>?): Map<String, String> {
|
||||
val rawHeaders = headers ?: return emptyMap()
|
||||
if (rawHeaders.isEmpty()) return emptyMap()
|
||||
|
|
@ -285,17 +58,12 @@ expect fun PlatformPlayerSurface(
|
|||
sourceAudioUrl: String? = null,
|
||||
sourceHeaders: Map<String, String> = emptyMap(),
|
||||
sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
streamType: String? = null,
|
||||
useYoutubeChunkedPlayback: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
playWhenReady: Boolean = true,
|
||||
resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
|
||||
initialPositionMs: Long = 0L,
|
||||
useNativeController: Boolean = false,
|
||||
playerControlsState: PlayerControlsState = PlayerControlsState(),
|
||||
onPlayerControlsAction: (PlayerControlsAction) -> Boolean = { false },
|
||||
onPlayerControlsEvent: (String, Double) -> Boolean = { _, _ -> false },
|
||||
onPlayerControlsScrubChange: (Long) -> Boolean = { false },
|
||||
onPlayerControlsScrubFinished: (Long) -> Boolean = { false },
|
||||
onControllerReady: (PlayerEngineController) -> Unit,
|
||||
onSnapshot: (PlayerPlaybackSnapshot) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -50,15 +49,16 @@ import androidx.compose.ui.draw.blur
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamCard
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||
|
|
@ -467,6 +467,10 @@ private fun EpisodeStreamsSubView(
|
|||
DebridSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val streamBadgeSettings by remember {
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
val episode = state.selectedEpisode ?: return
|
||||
val streamsUiState = state.streamsUiState
|
||||
|
|
@ -606,9 +610,14 @@ private fun EpisodeStreamsSubView(
|
|||
items = streams,
|
||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||
) { _, stream ->
|
||||
EpisodeSourceStreamRow(
|
||||
StreamCard(
|
||||
stream = stream,
|
||||
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
|
||||
!debridSettings.hasCustomStreamFormatting,
|
||||
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
|
||||
showAddonLogo = streamBadgeSettings.showAddonLogo,
|
||||
badgePlacement = streamBadgeSettings.badgePlacement,
|
||||
onClick = { onStreamSelected(stream, episode) },
|
||||
)
|
||||
}
|
||||
|
|
@ -617,53 +626,3 @@ private fun EpisodeStreamsSubView(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeSourceStreamRow(
|
||||
stream: StreamItem,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(tokens.colors.surfaceCard)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = tokens.spacing.cardPadding, vertical = tokens.spacing.listGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stream.streamLabel,
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
stream.streamSubtitle?.let { subtitle ->
|
||||
if (subtitle != stream.streamLabel) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = tokens.colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stream.addonName,
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontStyle = FontStyle.Italic,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ data class PlayerLaunch(
|
|||
val sourceAudioUrl: String? = null,
|
||||
val sourceHeaders: Map<String, String> = emptyMap(),
|
||||
val sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
val streamType: String? = null,
|
||||
val logo: String? = null,
|
||||
val poster: String? = null,
|
||||
val background: String? = null,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -62,7 +65,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.nuvioTypeScale
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
|
@ -122,6 +125,8 @@ internal fun OpeningOverlay(
|
|||
),
|
||||
label = "openingOverlayContentScale",
|
||||
)
|
||||
var logoLoadError by remember(logo) { mutableStateOf(false) }
|
||||
val logoUrl = logo?.takeIf { it.isNotBlank() }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
|
@ -178,14 +183,14 @@ internal fun OpeningOverlay(
|
|||
label = "openingOverlayP2pProgress",
|
||||
)
|
||||
val progressActive = targetProgress != null
|
||||
if (logo != null) {
|
||||
if (logoUrl != null && !logoLoadError) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(180.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
model = logoUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -197,10 +202,11 @@ internal fun OpeningOverlay(
|
|||
}
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
onError = { logoLoadError = true },
|
||||
)
|
||||
if (progressActive) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
model = logoUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -377,6 +383,9 @@ internal fun PauseMetadataOverlay(
|
|||
horizontalSafePadding: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var logoLoadError by remember(logo) { mutableStateOf(false) }
|
||||
val logoUrl = logo?.takeIf { it.isNotBlank() }
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.background(
|
||||
|
|
@ -429,13 +438,14 @@ internal fun PauseMetadataOverlay(
|
|||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(if (compactHeight) 8.dp else 12.dp))
|
||||
|
||||
if (!logo.isNullOrBlank()) {
|
||||
if (logoUrl != null && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
model = logoUrl,
|
||||
contentDescription = title,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.BottomStart,
|
||||
modifier = Modifier.height(logoHeight),
|
||||
onError = { logoLoadError = true },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ fun PlayerScreen(
|
|||
sourceAudioUrl: String? = null,
|
||||
sourceHeaders: Map<String, String> = emptyMap(),
|
||||
sourceResponseHeaders: Map<String, String> = emptyMap(),
|
||||
streamType: String? = null,
|
||||
providerName: String,
|
||||
streamTitle: String,
|
||||
streamSubtitle: String?,
|
||||
|
|
@ -44,6 +45,7 @@ fun PlayerScreen(
|
|||
sourceAudioUrl = sourceAudioUrl,
|
||||
sourceHeaders = sourceHeaders,
|
||||
sourceResponseHeaders = sourceResponseHeaders,
|
||||
streamType = streamType,
|
||||
providerName = providerName,
|
||||
streamTitle = streamTitle,
|
||||
streamSubtitle = streamSubtitle,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ internal data class PlayerScreenArgs(
|
|||
val sourceAudioUrl: String?,
|
||||
val sourceHeaders: Map<String, String>,
|
||||
val sourceResponseHeaders: Map<String, String>,
|
||||
val streamType: String?,
|
||||
val providerName: String,
|
||||
val streamTitle: String,
|
||||
val streamSubtitle: String?,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
|||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.isDesktop
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -231,10 +230,6 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
initialSeekApplied = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (isDesktop && activeInitialPositionMs > 0L) {
|
||||
initialSeekApplied = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
controller.seekTo(targetPositionMs)
|
||||
initialSeekApplied = true
|
||||
|
|
@ -244,8 +239,17 @@ internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
|
|||
BindPlayerMetadataAndSkipEffects()
|
||||
|
||||
DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
|
||||
val effectVideoId = playbackSession.videoId
|
||||
val effectSourceUrl = activeSourceUrl
|
||||
val effectSourceAudioUrl = activeSourceAudioUrl
|
||||
onDispose {
|
||||
flushWatchProgress()
|
||||
if (
|
||||
playbackSession.videoId == effectVideoId &&
|
||||
activeSourceUrl == effectSourceUrl &&
|
||||
activeSourceAudioUrl == effectSourceAudioUrl
|
||||
) {
|
||||
flushWatchProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -551,6 +555,7 @@ internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message:
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
|
||||
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
|
||||
activeStreamType = stream.streamType
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ internal data class PlayerSurfaceGestureCallbacks(
|
|||
val clearLiveGestureFeedback: State<() -> Unit>,
|
||||
val revealLockedOverlay: State<() -> Unit>,
|
||||
val isHoldToSpeedGestureActive: State<Boolean>,
|
||||
val touchGesturesEnabled: State<Boolean>,
|
||||
val playerControlsLocked: State<Boolean>,
|
||||
val currentPositionMs: State<Long>,
|
||||
val currentDurationMs: State<Long>,
|
||||
|
|
@ -162,33 +163,10 @@ internal fun PlayerScreenRuntime.togglePlayback() {
|
|||
controlsVisible = true
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareTogglePlaybackForNativeFallback(revealControls: Boolean = true) {
|
||||
shouldPlay = !playbackSnapshot.isPlaying
|
||||
if (revealControls) {
|
||||
controlsVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) {
|
||||
playerController?.seekBy(offsetMs)
|
||||
applySeekByControlFeedback(offsetMs)
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareSeekByForNativeFallback(
|
||||
offsetMs: Long,
|
||||
revealControls: Boolean = true,
|
||||
) {
|
||||
applySeekByControlFeedback(offsetMs, revealControls)
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.applySeekByControlFeedback(
|
||||
offsetMs: Long,
|
||||
revealControls: Boolean = true,
|
||||
) {
|
||||
scheduleProgressSyncAfterSeek()
|
||||
if (revealControls) {
|
||||
controlsVisible = true
|
||||
}
|
||||
controlsVisible = true
|
||||
when {
|
||||
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
|
||||
offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
|
||||
|
|
@ -196,17 +174,6 @@ private fun PlayerScreenRuntime.applySeekByControlFeedback(
|
|||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) {
|
||||
handleDoubleTapSeek(direction, sendToController = true)
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.prepareDoubleTapSeekForNativeFallback(direction: PlayerSeekDirection) {
|
||||
handleDoubleTapSeek(direction, sendToController = false)
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.handleDoubleTapSeek(
|
||||
direction: PlayerSeekDirection,
|
||||
sendToController: Boolean,
|
||||
) {
|
||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||
val currentSeekState = accumulatedSeekState
|
||||
val nextState = if (currentSeekState?.direction == direction) {
|
||||
|
|
@ -230,9 +197,7 @@ private fun PlayerScreenRuntime.handleDoubleTapSeek(
|
|||
maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
|
||||
}
|
||||
}
|
||||
if (sendToController) {
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
}
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
scheduleProgressSyncAfterSeek()
|
||||
showSeekFeedback(direction, nextState.amountMs)
|
||||
|
||||
|
|
@ -314,6 +279,10 @@ internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfac
|
|||
revealLockedOverlay()
|
||||
return@rememberUpdatedState
|
||||
}
|
||||
if (!playerSettingsUiState.touchGesturesEnabled) {
|
||||
controlsVisible = !controlsVisible
|
||||
return@rememberUpdatedState
|
||||
}
|
||||
when {
|
||||
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
|
||||
handleDoubleTapSeek(PlayerSeekDirection.Backward)
|
||||
|
|
@ -335,6 +304,7 @@ internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfac
|
|||
clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback),
|
||||
revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay),
|
||||
isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive),
|
||||
touchGesturesEnabled = rememberUpdatedState(playerSettingsUiState.touchGesturesEnabled),
|
||||
playerControlsLocked = rememberUpdatedState(playerControlsLocked),
|
||||
currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)),
|
||||
currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs),
|
||||
|
|
|
|||
|
|
@ -80,17 +80,40 @@ internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
|
|||
.coerceIn(0f, 100f)
|
||||
}
|
||||
|
||||
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
|
||||
internal data class TraktScrobbleItemInputs(
|
||||
val contentType: String,
|
||||
val parentMetaId: String,
|
||||
val videoId: String?,
|
||||
val title: String,
|
||||
val seasonNumber: Int?,
|
||||
val episodeNumber: Int?,
|
||||
val episodeTitle: String?,
|
||||
)
|
||||
|
||||
internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs(
|
||||
contentType = contentType ?: parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
videoId = activeVideoId,
|
||||
title = title,
|
||||
seasonNumber = activeSeasonNumber,
|
||||
episodeNumber = activeEpisodeNumber,
|
||||
episodeTitle = activeEpisodeTitle,
|
||||
)
|
||||
|
||||
private suspend fun TraktScrobbleItemInputs.buildItem() =
|
||||
TraktScrobbleRepository.buildItem(
|
||||
contentType = contentType ?: parentMetaType,
|
||||
contentType = contentType,
|
||||
parentMetaId = parentMetaId,
|
||||
videoId = activeVideoId,
|
||||
videoId = videoId,
|
||||
title = title,
|
||||
seasonNumber = activeSeasonNumber,
|
||||
episodeNumber = activeEpisodeNumber,
|
||||
episodeTitle = activeEpisodeTitle,
|
||||
seasonNumber = seasonNumber,
|
||||
episodeNumber = episodeNumber,
|
||||
episodeTitle = episodeTitle,
|
||||
)
|
||||
|
||||
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
|
||||
snapshotTraktScrobbleItemInputs().buildItem()
|
||||
|
||||
internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
|
||||
if (hasRequestedScrobbleStartForCurrentItem) return
|
||||
hasRequestedScrobbleStartForCurrentItem = true
|
||||
|
|
@ -120,8 +143,9 @@ internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? =
|
|||
|
||||
val percent = provided ?: currentPlaybackProgressPercent()
|
||||
val itemSnapshot = currentTraktScrobbleItem
|
||||
val inputsSnapshot = snapshotTraktScrobbleItemInputs()
|
||||
scope.launch(NonCancellable) {
|
||||
val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
|
||||
val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch
|
||||
TraktScrobbleRepository.scrobbleStop(
|
||||
item = item,
|
||||
progressPercent = percent,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean =
|
|||
|
||||
internal fun StreamItem.playerSourceIdentityKey(): String? {
|
||||
p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
return "torrent:$hash:${fileIdx ?: -1}"
|
||||
return "torrent:$hash:${p2pFileIdx ?: -1}"
|
||||
}
|
||||
|
||||
clientResolve?.let { resolve ->
|
||||
|
|
@ -138,7 +138,7 @@ internal fun PlayerScreenRuntime.saveP2pStreamForReuse(
|
|||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
infoHash = infoHash,
|
||||
fileIdx = stream.fileIdx,
|
||||
fileIdx = stream.p2pFileIdx,
|
||||
sources = stream.sources,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
)
|
||||
|
|
@ -160,12 +160,13 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
|
|||
season = activeSeasonNumber,
|
||||
episode = activeEpisodeNumber,
|
||||
)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
|
||||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeStreamType = null
|
||||
activeTorrentInfoHash = infoHash
|
||||
activeTorrentFileIdx = stream.fileIdx
|
||||
activeTorrentFileIdx = stream.p2pFileIdx
|
||||
activeTorrentFilename = stream.behaviorHints.filename
|
||||
activeTorrentTrackers = stream.p2pTrackers
|
||||
activeSourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
|
|
@ -202,12 +203,13 @@ internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
|
|||
season = episode.season,
|
||||
episode = episode.episode,
|
||||
)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
|
||||
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
|
||||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeStreamType = null
|
||||
activeTorrentInfoHash = infoHash
|
||||
activeTorrentFileIdx = stream.fileIdx
|
||||
activeTorrentFileIdx = stream.p2pFileIdx
|
||||
activeTorrentFilename = stream.behaviorHints.filename
|
||||
activeTorrentTrackers = stream.p2pTrackers
|
||||
applyEpisodeStreamMetadata(stream, episode, resume)
|
||||
|
|
@ -255,6 +257,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
|
||||
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
|
||||
activeStreamType = stream.streamType
|
||||
activeSourceIdentityKey = sourceIdentityKey
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
|
|
@ -302,6 +305,7 @@ internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episo
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
|
||||
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
|
||||
activeStreamType = stream.streamType
|
||||
applyEpisodeStreamMetadata(stream, episode, resume)
|
||||
}
|
||||
|
||||
|
|
@ -329,6 +333,7 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeStreamType = null
|
||||
activeSourceIdentityKey = null
|
||||
activeStreamTitle = downloadItem.streamTitle.ifBlank {
|
||||
episode.title.ifBlank { title }
|
||||
|
|
@ -476,5 +481,6 @@ private fun PlayerScreenRuntime.saveDirectStreamForReuse(
|
|||
filename = stream.behaviorHints.filename,
|
||||
videoSize = stream.behaviorHints.videoSize,
|
||||
bingeGroup = stream.behaviorHints.bingeGroup,
|
||||
streamType = stream.streamType,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ internal class PlayerScreenRuntime(
|
|||
val sourceAudioUrl: String? get() = args.sourceAudioUrl
|
||||
val sourceHeaders: Map<String, String> get() = args.sourceHeaders
|
||||
val sourceResponseHeaders: Map<String, String> get() = args.sourceResponseHeaders
|
||||
val streamType: String? get() = args.streamType
|
||||
val providerName: String get() = args.providerName
|
||||
val streamTitle: String get() = args.streamTitle
|
||||
val streamSubtitle: String? get() = args.streamSubtitle
|
||||
|
|
@ -66,8 +67,8 @@ internal class PlayerScreenRuntime(
|
|||
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
|
||||
var watchedUiState: WatchedUiState = WatchedUiState()
|
||||
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
|
||||
var sourceStreamsState by mutableStateOf(StreamsUiState())
|
||||
var episodeStreamsRepoState by mutableStateOf(StreamsUiState())
|
||||
var sourceStreamsState: StreamsUiState = StreamsUiState()
|
||||
var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
|
||||
var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
|
||||
var addonsUiState: AddonsUiState = AddonsUiState()
|
||||
var addonSubtitles: List<AddonSubtitle> = emptyList()
|
||||
|
|
@ -95,6 +96,7 @@ internal class PlayerScreenRuntime(
|
|||
var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl)
|
||||
var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
|
||||
var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
|
||||
var activeStreamType by mutableStateOf(streamType)
|
||||
var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
|
||||
var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
|
||||
var activeTorrentFilename by mutableStateOf(torrentFilename)
|
||||
|
|
@ -155,12 +157,6 @@ internal class PlayerScreenRuntime(
|
|||
var submitIntroSegmentType by mutableStateOf("intro")
|
||||
var submitIntroStartTimeStr by mutableStateOf("00:00")
|
||||
var submitIntroEndTimeStr by mutableStateOf("00:00")
|
||||
var submitIntroStartTimeSec by mutableStateOf<Double?>(0.0)
|
||||
var submitIntroEndTimeSec by mutableStateOf<Double?>(0.0)
|
||||
var isSubmitIntroSubmitting by mutableStateOf(false)
|
||||
var submitIntroStatusMessage by mutableStateOf<String?>(null)
|
||||
var playerControlsPendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
|
||||
var playerControlsCloseModalsToken by mutableStateOf(0L)
|
||||
var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState())
|
||||
var playerMetaVideos by mutableStateOf<List<MetaVideo>>(emptyList())
|
||||
var skipIntervals by mutableStateOf<List<SkipInterval>>(emptyList())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import com.nuvio.app.core.i18n.localizedNoSubtitleLinesFound
|
||||
import com.nuvio.app.core.i18n.localizedSubtitleLinesLoadError
|
||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -33,13 +35,13 @@ internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false
|
|||
subtitleAutoSyncState = subtitleAutoSyncState.copy(
|
||||
cues = cues,
|
||||
isLoading = false,
|
||||
errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
|
||||
errorMessage = if (cues.isEmpty()) localizedNoSubtitleLinesFound() else null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
subtitleAutoSyncState = subtitleAutoSyncState.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Unable to load subtitle lines",
|
||||
errorMessage = error.message ?: localizedSubtitleLinesLoadError(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -36,6 +36,7 @@ data class PlayerSettingsUiState(
|
|||
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
|
||||
val holdToSpeedEnabled: Boolean = true,
|
||||
val holdToSpeedValue: Float = 2f,
|
||||
val touchGesturesEnabled: Boolean = true,
|
||||
val externalPlayerEnabled: Boolean = false,
|
||||
val externalPlayerForwardSubtitles: Boolean = false,
|
||||
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
|
||||
|
|
@ -95,6 +96,7 @@ object PlayerSettingsRepository {
|
|||
private var resizeMode = PlayerResizeMode.Fit
|
||||
private var holdToSpeedEnabled = true
|
||||
private var holdToSpeedValue = 2f
|
||||
private var touchGesturesEnabled = true
|
||||
private var externalPlayerEnabled = false
|
||||
private var externalPlayerForwardSubtitles = false
|
||||
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
|
||||
|
|
@ -159,6 +161,7 @@ object PlayerSettingsRepository {
|
|||
resizeMode = PlayerResizeMode.Fit
|
||||
holdToSpeedEnabled = true
|
||||
holdToSpeedValue = 2f
|
||||
touchGesturesEnabled = true
|
||||
externalPlayerEnabled = false
|
||||
externalPlayerForwardSubtitles = false
|
||||
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
|
||||
|
|
@ -218,6 +221,7 @@ object PlayerSettingsRepository {
|
|||
?: PlayerResizeMode.Fit
|
||||
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
|
||||
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
|
||||
touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true
|
||||
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
|
||||
externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false
|
||||
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
|
||||
|
|
@ -369,6 +373,14 @@ object PlayerSettingsRepository {
|
|||
PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
|
||||
}
|
||||
|
||||
fun setTouchGesturesEnabled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (touchGesturesEnabled == enabled) return
|
||||
touchGesturesEnabled = enabled
|
||||
publish()
|
||||
PlayerSettingsStorage.saveTouchGesturesEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setExternalPlayerEnabled(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (enabled && externalPlayerId.isNullOrBlank()) {
|
||||
|
|
@ -828,6 +840,7 @@ object PlayerSettingsRepository {
|
|||
resizeMode = resizeMode,
|
||||
holdToSpeedEnabled = holdToSpeedEnabled,
|
||||
holdToSpeedValue = holdToSpeedValue,
|
||||
touchGesturesEnabled = touchGesturesEnabled,
|
||||
externalPlayerEnabled = externalPlayerEnabled,
|
||||
externalPlayerForwardSubtitles = externalPlayerForwardSubtitles,
|
||||
externalPlayerId = externalPlayerId,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ internal expect object PlayerSettingsStorage {
|
|||
fun saveHoldToSpeedEnabled(enabled: Boolean)
|
||||
fun loadHoldToSpeedValue(): Float?
|
||||
fun saveHoldToSpeedValue(speed: Float)
|
||||
fun loadTouchGesturesEnabled(): Boolean?
|
||||
fun saveTouchGesturesEnabled(enabled: Boolean)
|
||||
fun loadExternalPlayerEnabled(): Boolean?
|
||||
fun saveExternalPlayerEnabled(enabled: Boolean)
|
||||
fun loadExternalPlayerForwardSubtitles(): Boolean?
|
||||
|
|
|
|||
|
|
@ -15,11 +15,8 @@ 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.RowScope
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
|
|
@ -39,19 +36,13 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamBadge
|
||||
import com.nuvio.app.features.streams.StreamBadgeImage
|
||||
import com.nuvio.app.features.streams.StreamBadgePlacement
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamFileSizeBadge
|
||||
import com.nuvio.app.features.streams.StreamCard
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||
|
|
@ -224,12 +215,16 @@ fun PlayerSourcesPanel(
|
|||
currentUrl = currentStreamUrl,
|
||||
currentName = currentStreamName,
|
||||
)
|
||||
SourceStreamRow(
|
||||
StreamCard(
|
||||
stream = stream,
|
||||
isCurrent = isCurrent,
|
||||
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
|
||||
!debridSettings.hasCustomStreamFormatting,
|
||||
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
|
||||
showAddonLogo = streamBadgeSettings.showAddonLogo,
|
||||
badgePlacement = streamBadgeSettings.badgePlacement,
|
||||
isCurrent = isCurrent,
|
||||
currentLabel = stringResource(Res.string.compose_player_playing),
|
||||
onClick = { onStreamSelected(stream) },
|
||||
)
|
||||
}
|
||||
|
|
@ -243,150 +238,6 @@ fun PlayerSourcesPanel(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceStreamRow(
|
||||
stream: StreamItem,
|
||||
isCurrent: Boolean,
|
||||
enabled: Boolean,
|
||||
showFileSizeBadges: Boolean,
|
||||
badgePlacement: StreamBadgePlacement,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val cardShape = tokens.shapes.compactCard
|
||||
val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
|
||||
val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = NuvioTokens.Space.s64 + NuvioTokens.Space.s4)
|
||||
.shadow(
|
||||
elevation = tokens.elevation.raised,
|
||||
shape = cardShape,
|
||||
ambientColor = tokens.colors.overlayScrim.copy(alpha = tokens.opacity.subtle),
|
||||
spotColor = tokens.colors.overlayScrim.copy(alpha = tokens.opacity.subtle),
|
||||
)
|
||||
.clip(cardShape)
|
||||
.background(
|
||||
if (isCurrent) tokens.colors.overlaySelected else tokens.colors.surfaceCard,
|
||||
)
|
||||
.then(
|
||||
if (isCurrent) {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, cardShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(tokens.spacing.cardPaddingCompact),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (hasBadgeMetadata && badgePlacement == StreamBadgePlacement.TOP) {
|
||||
SourceStreamBadgeRow(
|
||||
badgeImages = badgeImages,
|
||||
stream = stream,
|
||||
showFileSizeBadges = showFileSizeBadges,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s6))
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
) {
|
||||
Text(
|
||||
text = stream.streamLabel,
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = NuvioTokens.LetterSpacing.none,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (isCurrent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.chip)
|
||||
.background(tokens.colors.accent)
|
||||
.padding(horizontal = NuvioTokens.Space.s8, vertical = NuvioTokens.Space.s3),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_playing),
|
||||
color = tokens.colors.onAccent,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val subtitle = stream.streamSubtitle
|
||||
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s2))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = tokens.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s6))
|
||||
if (badgePlacement == StreamBadgePlacement.BOTTOM) {
|
||||
SourceStreamBadgeRow(
|
||||
badgeImages = badgeImages,
|
||||
stream = stream,
|
||||
showFileSizeBadges = showFileSizeBadges,
|
||||
) {
|
||||
Text(
|
||||
text = stream.addonName,
|
||||
modifier = if (hasBadgeMetadata) Modifier.padding(start = NuvioTokens.Space.s4) else Modifier,
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontStyle = FontStyle.Italic,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stream.addonName,
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontStyle = FontStyle.Italic,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceStreamBadgeRow(
|
||||
badgeImages: List<StreamBadge>,
|
||||
stream: StreamItem,
|
||||
showFileSizeBadges: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingContent: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
|
||||
) {
|
||||
badgeImages.forEach { badge ->
|
||||
StreamBadgeImage(badge = badge)
|
||||
}
|
||||
if (showFileSizeBadges) {
|
||||
StreamFileSizeBadge(stream = stream)
|
||||
}
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AddonFilterChip(
|
||||
label: String,
|
||||
|
|
|
|||
|
|
@ -12,28 +12,35 @@ import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
|
|||
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.plugins.PluginsUiState
|
||||
import com.nuvio.app.features.plugins.pluginContentId
|
||||
import com.nuvio.app.features.plugins.PluginRuntimeResult
|
||||
import com.nuvio.app.features.plugins.PluginScraper
|
||||
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
|
||||
import com.nuvio.app.features.streams.AddonStreamGroup
|
||||
import com.nuvio.app.features.streams.InstalledStreamAddonTarget
|
||||
import com.nuvio.app.features.streams.StreamAutoPlaySelector
|
||||
import com.nuvio.app.features.streams.StreamBadgePresentation
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamLoadCompletion
|
||||
import com.nuvio.app.features.streams.StreamParser
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.streams.runCatchingUnlessCancelled
|
||||
import com.nuvio.app.features.streams.sortedForGroupedDisplay
|
||||
import com.nuvio.app.features.streams.streamAddonInstanceId
|
||||
import com.nuvio.app.features.streams.toEmptyStateReason
|
||||
import com.nuvio.app.features.streams.toPluginProviderGroups
|
||||
import com.nuvio.app.features.streams.toStreamItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Dedicated stream fetcher for use inside the player (sources & episodes panels).
|
||||
|
|
@ -63,7 +70,6 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "sources",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -85,7 +91,6 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "episodeStreams",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -121,7 +126,6 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
private fun fetchStreams(
|
||||
panelName: String,
|
||||
type: String,
|
||||
videoId: String,
|
||||
season: Int?,
|
||||
|
|
@ -133,18 +137,22 @@ object PlayerStreamsRepository {
|
|||
jobHolder: () -> Job?,
|
||||
setJob: (Job) -> Unit,
|
||||
) {
|
||||
val requestKey = "$type::$videoId::$season::$episode"
|
||||
val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) {
|
||||
PluginRepository.initialize()
|
||||
PluginRepository.uiState.value
|
||||
} else {
|
||||
PluginsUiState(pluginsEnabled = false)
|
||||
}
|
||||
val requestKey = "$type::$videoId::$season::$episode::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
|
||||
val current = stateFlow.value
|
||||
if (
|
||||
!forceRefresh &&
|
||||
requestKeyHolder() == requestKey &&
|
||||
(current.groups.isNotEmpty() || current.emptyStateReason != null || current.isAnyLoading)
|
||||
) {
|
||||
log.d { "skip $panelName request=$requestKey reason=already-active ${current.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
log.d { "start $panelName request=$requestKey force=$forceRefresh previous=${current.streamDiagnostics()}" }
|
||||
setRequestKey(requestKey)
|
||||
jobHolder()?.cancel()
|
||||
stateFlow.value = StreamsUiState()
|
||||
|
|
@ -152,7 +160,7 @@ object PlayerStreamsRepository {
|
|||
val streamBadgeRules = StreamBadgeSettingsRepository.snapshot()
|
||||
val embeddedStreams = MetaDetailsRepository.findEmbeddedStreams(videoId)
|
||||
if (embeddedStreams.isNotEmpty()) {
|
||||
log.d { "using embedded $panelName request=$requestKey streams=${embeddedStreams.size}" }
|
||||
log.d { "Using ${embeddedStreams.size} embedded streams for type=$type id=$videoId" }
|
||||
val group = AddonStreamGroup(
|
||||
addonName = embeddedStreams.first().addonName,
|
||||
addonId = "embedded",
|
||||
|
|
@ -168,28 +176,28 @@ object PlayerStreamsRepository {
|
|||
activeAddonIds = setOf("embedded"),
|
||||
isAnyLoading = false,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
val installedAddons = AddonRepository.uiState.value.addons.enabledAddons()
|
||||
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
val playerSettings = PlayerSettingsRepository.uiState.value
|
||||
val debridSettings = DebridSettingsRepository.snapshot()
|
||||
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
|
||||
PluginRepository.initialize()
|
||||
PluginRepository.getEnabledScrapersForType(type)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val pluginProviderGroups = pluginScrapers.toPluginProviderGroups(
|
||||
repositories = pluginUiState.repositories,
|
||||
groupByRepository = pluginUiState.groupStreamsByRepository,
|
||||
)
|
||||
|
||||
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
|
||||
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||
stateFlow.value = StreamsUiState(
|
||||
isAnyLoading = false,
|
||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -204,50 +212,33 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
if (!supportsRequestedStream) return@mapNotNull null
|
||||
|
||||
PlayerInstalledStreamAddonTarget(
|
||||
InstalledStreamAddonTarget(
|
||||
addonName = addon.displayTitle.ifBlank { manifest.name },
|
||||
addonId = addon.streamAddonInstanceId(manifest.id),
|
||||
manifest = manifest,
|
||||
)
|
||||
}
|
||||
|
||||
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
|
||||
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
|
||||
stateFlow.value = StreamsUiState(
|
||||
isAnyLoading = false,
|
||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
|
||||
)
|
||||
log.d {
|
||||
"finish $panelName request=$requestKey reason=no-compatible-addons " +
|
||||
"installed=${installedAddons.size} ${stateFlow.value.streamDiagnostics()}"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val installedAddonOrder = streamAddons.map { it.addonName }
|
||||
val warmedAddonGroups = if (forceRefresh) {
|
||||
emptyMap()
|
||||
} else {
|
||||
AddonStreamWarmupRepository
|
||||
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
|
||||
.orEmpty()
|
||||
.associateBy { it.addonId }
|
||||
}
|
||||
val warmedAddonIds = warmedAddonGroups.keys
|
||||
log.d {
|
||||
"targets $panelName request=$requestKey installed=${installedAddons.size} " +
|
||||
"compatible=${streamAddons.size} plugins=${pluginScrapers.size} warmed=${warmedAddonIds.size}"
|
||||
}
|
||||
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
|
||||
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
|
||||
AddonStreamGroup(
|
||||
addonName = addon.addonName,
|
||||
addonId = addon.addonId,
|
||||
streams = emptyList(),
|
||||
isLoading = true,
|
||||
)
|
||||
} + pluginScrapers.map { scraper ->
|
||||
} + pluginProviderGroups.map { providerGroup ->
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
addonName = providerGroup.addonName,
|
||||
addonId = providerGroup.addonId,
|
||||
streams = emptyList(),
|
||||
isLoading = true,
|
||||
)
|
||||
|
|
@ -258,22 +249,23 @@ object PlayerStreamsRepository {
|
|||
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
|
||||
isAnyLoading = isInitiallyLoading,
|
||||
)
|
||||
log.d { "state $panelName request=$requestKey stage=initial ${stateFlow.value.streamDiagnostics()}" }
|
||||
|
||||
val job = scope.launch {
|
||||
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
|
||||
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
|
||||
val installedAddonNames = installedAddonOrder.toSet()
|
||||
val pluginRemainingByAddonId = pluginProviderGroups
|
||||
.associate { it.addonId to it.scrapers.size }
|
||||
.toMutableMap()
|
||||
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
|
||||
val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size }
|
||||
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
|
||||
val debridAvailabilityJobs = mutableListOf<Job>()
|
||||
fun emptyStateReason(groups: List<AddonStreamGroup>, anyLoading: Boolean) =
|
||||
if (!anyLoading && groups.all { it.streams.isEmpty() }) {
|
||||
if (groups.all { !it.error.isNullOrBlank() }) {
|
||||
com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed
|
||||
} else {
|
||||
com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
||||
fun publishCompletion(completion: StreamLoadCompletion) {
|
||||
if (completions.trySend(completion).isFailure) {
|
||||
log.d { "Ignoring late player stream load completion after channel close" }
|
||||
}
|
||||
}
|
||||
|
||||
fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup {
|
||||
val badgeGroup = StreamBadgePresentation.apply(
|
||||
|
|
@ -287,7 +279,6 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
fun publishStreamGroup(group: AddonStreamGroup) {
|
||||
var nextState: StreamsUiState? = null
|
||||
stateFlow.update { current ->
|
||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||
groups = current.groups.map { currentGroup ->
|
||||
|
|
@ -299,15 +290,8 @@ object PlayerStreamsRepository {
|
|||
current.copy(
|
||||
groups = updated,
|
||||
isAnyLoading = anyLoading,
|
||||
emptyStateReason = emptyStateReason(updated, anyLoading),
|
||||
).also { nextState = it }
|
||||
}
|
||||
nextState?.let { state ->
|
||||
log.d {
|
||||
"state $panelName request=$requestKey stage=publish addon=${group.addonName} " +
|
||||
"streams=${group.streams.size} loading=${group.isLoading} " +
|
||||
"error=${!group.error.isNullOrBlank()} ${state.streamDiagnostics()}"
|
||||
}
|
||||
emptyStateReason = updated.toEmptyStateReason(anyLoading),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -342,8 +326,8 @@ object PlayerStreamsRepository {
|
|||
debridAvailabilityJobs += availabilityJob
|
||||
}
|
||||
|
||||
val addonJobs = pendingStreamAddons.map { addon ->
|
||||
async {
|
||||
streamAddons.forEach { addon ->
|
||||
launch {
|
||||
val url = buildAddonResourceUrl(
|
||||
manifestUrl = addon.manifest.transportUrl,
|
||||
resource = "stream",
|
||||
|
|
@ -352,75 +336,123 @@ object PlayerStreamsRepository {
|
|||
)
|
||||
|
||||
val displayName = addon.addonName
|
||||
runCatching {
|
||||
log.d { "fetch $panelName request=$requestKey addon=$displayName" }
|
||||
val group = runCatchingUnlessCancelled {
|
||||
val payload = httpGetText(url)
|
||||
StreamParser.parse(payload, displayName, addon.addonId)
|
||||
StreamParser.parse(
|
||||
payload = payload,
|
||||
addonName = displayName,
|
||||
addonId = addon.addonId,
|
||||
addonLogo = addon.manifest.logoUrl,
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { streams ->
|
||||
log.d { "fetched $panelName request=$requestKey addon=$displayName streams=${streams.size}" }
|
||||
AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false)
|
||||
},
|
||||
onFailure = { err ->
|
||||
log.w(err) { "failed $panelName request=$requestKey addon=$displayName" }
|
||||
log.w(err) { "Failed: ${displayName}" }
|
||||
AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message)
|
||||
},
|
||||
)
|
||||
publishCompletion(StreamLoadCompletion.Addon(group))
|
||||
}
|
||||
}
|
||||
|
||||
val pluginJobs = pluginScrapers.map { scraper ->
|
||||
async {
|
||||
log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = pluginContentId(
|
||||
videoId = videoId,
|
||||
pluginProviderGroups.forEach { providerGroup ->
|
||||
val includeScraperNameInSubtitle = false
|
||||
providerGroup.scrapers.forEach { scraper ->
|
||||
launch {
|
||||
val completion = PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = pluginContentId(
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
episode = episode,
|
||||
),
|
||||
mediaType = type,
|
||||
season = season,
|
||||
episode = episode,
|
||||
),
|
||||
mediaType = type,
|
||||
season = season,
|
||||
episode = episode,
|
||||
).fold(
|
||||
onSuccess = { results ->
|
||||
log.d { "fetched $panelName request=$requestKey plugin=${scraper.name} streams=${results.size}" }
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
streams = results.map { it.toStreamItem(scraper) },
|
||||
isLoading = false,
|
||||
)
|
||||
},
|
||||
onFailure = { err ->
|
||||
log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
streams = emptyList(),
|
||||
isLoading = false,
|
||||
error = err.message,
|
||||
)
|
||||
},
|
||||
)
|
||||
).fold(
|
||||
onSuccess = { results ->
|
||||
StreamLoadCompletion.PluginScraper(
|
||||
addonId = providerGroup.addonId,
|
||||
streams = results.map { result ->
|
||||
result.toStreamItem(
|
||||
scraper = scraper,
|
||||
addonName = providerGroup.addonName,
|
||||
addonId = providerGroup.addonId,
|
||||
includeScraperNameInSubtitle = includeScraperNameInSubtitle,
|
||||
)
|
||||
},
|
||||
error = null,
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
log.w(error) { "Plugin scraper failed: ${scraper.name}" }
|
||||
StreamLoadCompletion.PluginScraper(
|
||||
addonId = providerGroup.addonId,
|
||||
streams = emptyList(),
|
||||
error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name),
|
||||
)
|
||||
},
|
||||
)
|
||||
publishCompletion(completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val jobs = addonJobs + pluginJobs
|
||||
val completions = Channel<AddonStreamGroup>(capacity = Channel.BUFFERED)
|
||||
jobs.forEach { deferred ->
|
||||
launch {
|
||||
completions.send(deferred.await())
|
||||
repeat(totalTasks) {
|
||||
when (val completion = completions.receive()) {
|
||||
is StreamLoadCompletion.Addon -> {
|
||||
publishStreamGroupAfterCacheCheck(completion.group)
|
||||
}
|
||||
|
||||
is StreamLoadCompletion.PluginScraper -> {
|
||||
val remaining = (pluginRemainingByAddonId[completion.addonId] ?: 1) - 1
|
||||
pluginRemainingByAddonId[completion.addonId] = remaining.coerceAtLeast(0)
|
||||
if (!completion.error.isNullOrBlank() && pluginFirstErrorByAddonId[completion.addonId].isNullOrBlank()) {
|
||||
pluginFirstErrorByAddonId[completion.addonId] = completion.error
|
||||
}
|
||||
|
||||
stateFlow.update { current ->
|
||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||
groups = current.groups.map { group ->
|
||||
if (group.addonId != completion.addonId) {
|
||||
group
|
||||
} else {
|
||||
val mergedStreams = if (completion.streams.isEmpty()) {
|
||||
group.streams
|
||||
} else {
|
||||
(group.streams + completion.streams).sortedForGroupedDisplay()
|
||||
}
|
||||
val stillLoading = remaining > 0
|
||||
val finalError = if (mergedStreams.isEmpty() && !stillLoading) {
|
||||
pluginFirstErrorByAddonId[completion.addonId]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
group.copy(
|
||||
streams = mergedStreams,
|
||||
isLoading = stillLoading,
|
||||
error = finalError,
|
||||
)
|
||||
}
|
||||
},
|
||||
installedOrder = installedAddonOrder,
|
||||
)
|
||||
val anyLoading = updated.any { it.isLoading }
|
||||
current.copy(
|
||||
groups = updated,
|
||||
isAnyLoading = anyLoading,
|
||||
emptyStateReason = updated.toEmptyStateReason(anyLoading),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(jobs.size) {
|
||||
val result = completions.receive()
|
||||
publishStreamGroupAfterCacheCheck(result)
|
||||
}
|
||||
|
||||
for (availabilityJob in debridAvailabilityJobs) {
|
||||
availabilityJob.join()
|
||||
}
|
||||
log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" }
|
||||
launch {
|
||||
DirectDebridStreamPreparer.prepare(
|
||||
streams = stateFlow.value.groups
|
||||
|
|
@ -448,68 +480,3 @@ object PlayerStreamsRepository {
|
|||
setJob(job)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PlayerInstalledStreamAddonTarget(
|
||||
val addonName: String,
|
||||
val addonId: String,
|
||||
val manifest: com.nuvio.app.features.addons.AddonManifest,
|
||||
)
|
||||
|
||||
private fun StreamsUiState.streamDiagnostics(): String {
|
||||
val streamCount = groups.sumOf { it.streams.size }
|
||||
val loadingCount = groups.count { it.isLoading }
|
||||
val errorCount = groups.count { !it.error.isNullOrBlank() }
|
||||
val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group ->
|
||||
buildString {
|
||||
append(group.addonName)
|
||||
append(':')
|
||||
append(group.streams.size)
|
||||
if (group.isLoading) append(":loading")
|
||||
if (!group.error.isNullOrBlank()) append(":error")
|
||||
}
|
||||
}
|
||||
val suffix = if (groups.size > 4) "+${groups.size - 4}" else ""
|
||||
return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " +
|
||||
"loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " +
|
||||
"sample=$sampleGroups$suffix"
|
||||
}
|
||||
|
||||
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
|
||||
"addon:$manifestId:$manifestUrl"
|
||||
|
||||
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
|
||||
val subtitleParts = listOfNotNull(
|
||||
quality?.takeIf { it.isNotBlank() },
|
||||
size?.takeIf { it.isNotBlank() },
|
||||
language?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
val requestHeaders = headers
|
||||
.orEmpty()
|
||||
.mapNotNull { (key, value) ->
|
||||
val headerName = key.trim()
|
||||
val headerValue = value.trim()
|
||||
if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) {
|
||||
null
|
||||
} else {
|
||||
headerName to headerValue
|
||||
}
|
||||
}
|
||||
.toMap()
|
||||
|
||||
return StreamItem(
|
||||
name = name ?: title,
|
||||
description = subtitleParts.joinToString(" • ").ifBlank { null },
|
||||
url = url,
|
||||
infoHash = infoHash,
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
behaviorHints = if (requestHeaders.isEmpty()) {
|
||||
com.nuvio.app.features.streams.StreamBehaviorHints()
|
||||
} else {
|
||||
com.nuvio.app.features.streams.StreamBehaviorHints(
|
||||
notWebReady = true,
|
||||
proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ internal fun Modifier.playerSurfaceDragGestures(
|
|||
layoutSize: IntSize,
|
||||
sideGestureSystemEdgeExclusionPx: Float,
|
||||
playerControlsLockedState: State<Boolean>,
|
||||
touchGesturesEnabledState: State<Boolean>,
|
||||
isHoldToSpeedGestureActiveState: State<Boolean>,
|
||||
currentPositionMsState: State<Long>,
|
||||
currentDurationMsState: State<Long>,
|
||||
|
|
@ -66,6 +67,9 @@ internal fun Modifier.playerSurfaceDragGestures(
|
|||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
if (!touchGesturesEnabledState.value) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
val controller = gestureController
|
||||
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
||||
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_title_format
|
||||
import nuvio.composeapp.generated.resources.detail_btn_play
|
||||
|
|
|
|||
|
|
@ -32,22 +32,55 @@ object PlayerNextEpisodeRules {
|
|||
thresholdPercent: Float,
|
||||
thresholdMinutesBeforeEnd: Float,
|
||||
): Boolean {
|
||||
val outroInterval = skipIntervals.firstOrNull { it.type == "outro" }
|
||||
return if (outroInterval != null) {
|
||||
positionMs / 1000.0 >= outroInterval.startTime
|
||||
} else {
|
||||
val outroSegments = skipIntervals.filter { it.type in OUTRO_SEGMENT_TYPES }
|
||||
|
||||
if (outroSegments.isNotEmpty()) {
|
||||
if (durationMs <= 0L) return false
|
||||
when (thresholdMode) {
|
||||
val latestOutroEndMs = (outroSegments.maxOf { it.endTime } * 1_000.0).toLong()
|
||||
val postOutroGapMs = durationMs - latestOutroEndMs
|
||||
|
||||
// Calculate the user's configured threshold as milliseconds from end.
|
||||
val userThresholdMs = when (thresholdMode) {
|
||||
NextEpisodeThresholdMode.PERCENTAGE -> {
|
||||
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
|
||||
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
|
||||
((1.0 - clampedPercent / 100.0) * durationMs).toLong()
|
||||
}
|
||||
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
|
||||
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
|
||||
val remainingMs = durationMs - positionMs
|
||||
remainingMs <= (clampedMinutes * 60_000f).toLong()
|
||||
(clampedMinutes * 60_000f).toLong()
|
||||
}
|
||||
}
|
||||
|
||||
return if (postOutroGapMs > userThresholdMs) {
|
||||
when (thresholdMode) {
|
||||
NextEpisodeThresholdMode.PERCENTAGE -> {
|
||||
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
|
||||
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
|
||||
}
|
||||
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
|
||||
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
|
||||
val remainingMs = durationMs - positionMs
|
||||
remainingMs <= (clampedMinutes * 60_000f).toLong()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Outro ends close to the file end — fire at earliest outro start.
|
||||
positionMs / 1_000.0 >= outroSegments.minOf { it.startTime }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the settings threshold when no outro data exists.
|
||||
if (durationMs <= 0L) return false
|
||||
return when (thresholdMode) {
|
||||
NextEpisodeThresholdMode.PERCENTAGE -> {
|
||||
val clampedPercent = thresholdPercent.coerceIn(97f, 100f)
|
||||
(positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0)
|
||||
}
|
||||
NextEpisodeThresholdMode.MINUTES_BEFORE_END -> {
|
||||
val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f)
|
||||
val remainingMs = durationMs - positionMs
|
||||
remainingMs <= (clampedMinutes * 60_000f).toLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +109,8 @@ object PlayerNextEpisodeRules {
|
|||
if (m1 != m2) return m1.compareTo(m2)
|
||||
return d1.compareTo(d2)
|
||||
}
|
||||
|
||||
val OUTRO_SEGMENT_TYPES = setOf("outro", "ed", "mixed-ed")
|
||||
}
|
||||
|
||||
internal expect fun currentDateComponents(): DateComponents
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.ui.NuvioInputField
|
||||
|
|
|
|||
|
|
@ -38,9 +38,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -65,7 +62,6 @@ object ProfileRepository {
|
|||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("ProfileRepository")
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
private val profileSwitchMutex = Mutex()
|
||||
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
|
||||
|
||||
private val _state = MutableStateFlow(ProfileState())
|
||||
|
|
@ -145,15 +141,7 @@ object ProfileRepository {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun switchToProfile(profileIndex: Int) {
|
||||
profileSwitchMutex.withLock {
|
||||
withContext(Dispatchers.Default) {
|
||||
selectProfile(profileIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProfile(profileIndex: Int) {
|
||||
fun selectProfile(profileIndex: Int) {
|
||||
activeProfileIndex = profileIndex
|
||||
val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex }
|
||||
_state.value = _state.value.copy(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -176,6 +176,7 @@ fun ProfileSelectionScreen(
|
|||
} else if (profile.pinEnabled) {
|
||||
pinDialogProfile = profile
|
||||
} else {
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
}
|
||||
},
|
||||
|
|
@ -216,6 +217,7 @@ fun ProfileSelectionScreen(
|
|||
} else if (profile.pinEnabled) {
|
||||
pinDialogProfile = profile
|
||||
} else {
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
}
|
||||
},
|
||||
|
|
@ -280,6 +282,7 @@ fun ProfileSelectionScreen(
|
|||
onVerify = { pin -> ProfileRepository.verifyPin(profile.profileIndex, pin) },
|
||||
onVerified = {
|
||||
pinDialogProfile = null
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
onProfileSelected(profile)
|
||||
},
|
||||
onDismiss = { pinDialogProfile = null },
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.isIos
|
||||
|
|
@ -85,7 +85,6 @@ fun ProfileSwitcherTab(
|
|||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
triggerContent: (@Composable (selected: Boolean) -> Unit)? = null,
|
||||
openPopupOnClick: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
|
@ -202,13 +201,7 @@ fun ProfileSwitcherTab(
|
|||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {
|
||||
if (openPopupOnClick && profiles.isNotEmpty()) {
|
||||
showPopup = true
|
||||
} else {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
.pointerInput(profiles) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
|
|
@ -343,358 +336,6 @@ fun ProfileSwitcherTab(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SidebarProfileSwitcherStack(
|
||||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val activeProfile = profileState.activeProfile
|
||||
val profiles = profileState.profiles
|
||||
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
var pinProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
AvatarRepository.fetchAvatars()
|
||||
AvatarRepository.refreshAvatars()
|
||||
}
|
||||
|
||||
fun chooseProfile(profile: NuvioProfile) {
|
||||
if (profile.profileIndex == activeProfile?.profileIndex) {
|
||||
onDismissRequest()
|
||||
return
|
||||
}
|
||||
if (profile.pinEnabled) {
|
||||
pinProfile = profile
|
||||
} else {
|
||||
onProfileSelected(profile)
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
profiles.forEach { profile ->
|
||||
SidebarProfileSwitcherRow(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
isActive = profile.profileIndex == activeProfile?.profileIndex,
|
||||
onClick = { chooseProfile(profile) },
|
||||
)
|
||||
}
|
||||
|
||||
if (profiles.size < 4) {
|
||||
SidebarAddProfileRow(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAddProfileRequested()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = pinProfile != null,
|
||||
enter = expandVertically() + fadeIn(tween(160)),
|
||||
exit = shrinkVertically(tween(120)) + fadeOut(tween(90)),
|
||||
) {
|
||||
pinProfile?.let { profile ->
|
||||
SidebarCompactPinEntry(
|
||||
profileName = profile.name,
|
||||
onVerified = {
|
||||
onProfileSelected(profile)
|
||||
pinProfile = null
|
||||
onDismissRequest()
|
||||
},
|
||||
onCancel = { pinProfile = null },
|
||||
verifyPin = { pin ->
|
||||
ProfileRepository.verifyPin(profile.profileIndex, pin)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarProfileSwitcherRow(
|
||||
profile: NuvioProfile,
|
||||
avatars: List<AvatarCatalogItem>,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(
|
||||
if (isActive) {
|
||||
tokens.colors.overlaySelected
|
||||
} else {
|
||||
tokens.colors.surface.copy(alpha = 0f)
|
||||
},
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(30.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ActiveProfileMiniAvatar(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
selected = isActive,
|
||||
size = 26,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = profile.name.ifBlank {
|
||||
stringResource(Res.string.profile_label_number, profile.profileIndex)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isActive) tokens.colors.textPrimary else tokens.colors.textMuted,
|
||||
fontWeight = if (isActive) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (profile.pinEnabled) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Lock,
|
||||
contentDescription = null,
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarAddProfileRow(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surfaceCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(Res.string.compose_profile_add_profile),
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_profile_add_profile),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarCompactPinEntry(
|
||||
profileName: String,
|
||||
onVerified: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
verifyPin: suspend (String) -> PinVerifyResult,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
var pin by remember(profileName) { mutableStateOf("") }
|
||||
var error by remember(profileName) { mutableStateOf<String?>(null) }
|
||||
var isVerifying by remember(profileName) { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(tokens.colors.surfaceCard.copy(alpha = 0.72f))
|
||||
.padding(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.pin_enter_for, profileName),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
repeat(4) { index ->
|
||||
val filled = index < pin.length
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.then(
|
||||
if (filled) {
|
||||
Modifier.background(tokens.colors.accent)
|
||||
} else {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.avatar)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = error.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.danger,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SidebarCompactPinKeypad(
|
||||
onDigit = { digit ->
|
||||
if (pin.length < 4 && !isVerifying) {
|
||||
error = null
|
||||
pin += digit
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
if (pin.length == 4) {
|
||||
isVerifying = true
|
||||
scope.launch {
|
||||
val result = verifyPin(pin)
|
||||
if (result.unlocked) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onVerified()
|
||||
} else {
|
||||
error = if (result.retryAfterSeconds > 0) {
|
||||
getString(Res.string.pin_locked_try_again, result.retryAfterSeconds)
|
||||
} else {
|
||||
getString(Res.string.pin_incorrect)
|
||||
}
|
||||
pin = ""
|
||||
}
|
||||
isVerifying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onBackspace = {
|
||||
if (pin.isNotEmpty() && !isVerifying) {
|
||||
pin = pin.dropLast(1)
|
||||
error = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.pin_cancel),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tokens.colors.accent,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.clickable(onClick = onCancel)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarCompactPinKeypad(
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("", "0", "⌫"),
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
when (key) {
|
||||
"" -> Spacer(modifier = Modifier.size(30.dp))
|
||||
"⌫" -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surface)
|
||||
.clickable(onClick = onBackspace),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.Backspace,
|
||||
contentDescription = stringResource(Res.string.pin_backspace),
|
||||
tint = tokens.colors.textPrimary,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.surface)
|
||||
.clickable { onDigit(key) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = key,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PopupAddProfileBubble(
|
||||
delayMs: Int,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ 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 com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ data class DiscoverUiState(
|
|||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
val emptyStateReason: DiscoverEmptyStateReason? = null,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ import com.nuvio.app.features.addons.AddonCatalog
|
|||
import com.nuvio.app.features.addons.AddonExtraProperty
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.CatalogTarget
|
||||
import com.nuvio.app.features.catalog.buildCatalogUrl
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.nextCatalogPaginationState
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
|
|
@ -378,12 +381,15 @@ object SearchRepository {
|
|||
title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()),
|
||||
subtitle = addon.displayTitle,
|
||||
addonName = addon.displayTitle,
|
||||
type = type,
|
||||
manifestUrl = manifest.transportUrl,
|
||||
catalogId = catalogId,
|
||||
target = CatalogTarget.Addon(
|
||||
manifestUrl = manifest.transportUrl,
|
||||
contentType = type,
|
||||
catalogId = catalogId,
|
||||
supportsPagination = supportsPagination,
|
||||
),
|
||||
items = items,
|
||||
availableItemCount = page.rawItemCount,
|
||||
supportsPagination = supportsPagination,
|
||||
hasMore = supportsPagination && page.nextSkip != null,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -411,6 +417,7 @@ object SearchRepository {
|
|||
isLoading = true,
|
||||
items = if (reset) emptyList() else current.items,
|
||||
nextSkip = if (reset) null else current.nextSkip,
|
||||
consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages,
|
||||
emptyStateReason = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
|
@ -435,6 +442,15 @@ object SearchRepository {
|
|||
} else {
|
||||
mergeCatalogItems(latest.items, page.items)
|
||||
}
|
||||
val supportsPagination = selectedCatalog.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val loadedNewItems = reset || mergedItems.size > latest.items.size
|
||||
val paginationState = nextCatalogPaginationState(
|
||||
supportsPagination = supportsPagination,
|
||||
requestedSkip = requestedSkip,
|
||||
page = page,
|
||||
loadedNewItems = loadedNewItems,
|
||||
consecutiveDuplicatePages = if (reset) 0 else latest.consecutiveDuplicatePages,
|
||||
)
|
||||
log.d {
|
||||
"Discover response catalogKey=${selectedCatalog.key} returned=${page.items.size} " +
|
||||
"merged=${mergedItems.size} rawItemCount=${page.rawItemCount} nextSkip=${page.nextSkip} " +
|
||||
|
|
@ -443,7 +459,8 @@ object SearchRepository {
|
|||
_discoverUiState.value = latest.copy(
|
||||
items = mergedItems,
|
||||
isLoading = false,
|
||||
nextSkip = if (selectedCatalog.supportsPagination) page.nextSkip else null,
|
||||
nextSkip = paginationState.nextSkip,
|
||||
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
|
||||
emptyStateReason = if (mergedItems.isEmpty()) DiscoverEmptyStateReason.NoResults else null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||
@Composable
|
||||
fun SearchScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
searchFocusRequestCount: Int = 0,
|
||||
|
|
@ -239,7 +238,6 @@ fun SearchScreen(
|
|||
|
||||
NuvioScreen(
|
||||
horizontalPadding = 0.dp,
|
||||
topPadding = if (topChromePadding != null) 0.dp else null,
|
||||
listState = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
|
|
@ -257,7 +255,6 @@ fun SearchScreen(
|
|||
NuvioScreenHeader(
|
||||
title = headerTitle,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
topPadding = topChromePadding,
|
||||
)
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp))
|
||||
androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package com.nuvio.app.features.settings
|
|||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache
|
||||
import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done
|
||||
|
|
@ -43,6 +46,7 @@ internal fun LazyListScope.advancedSettingsContent(
|
|||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var cleared by rememberSaveable { mutableStateOf(false) }
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_advanced_clear_cw_cache),
|
||||
|
|
@ -56,6 +60,11 @@ internal fun LazyListScope.advancedSettingsContent(
|
|||
if (!cleared) {
|
||||
ContinueWatchingEnrichmentCache.clearAll()
|
||||
cleared = true
|
||||
scope.launch {
|
||||
WatchProgressRepository.forceSnapshotRefreshFromServer(
|
||||
ProfileRepository.activeProfileId,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -40,8 +40,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.isDesktop
|
||||
import com.nuvio.app.core.ui.AppTheme
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||
|
|
@ -59,8 +57,6 @@ import nuvio.composeapp.generated.resources.settings_appearance_app_language_she
|
|||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_black
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_amoled_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sheet_title
|
||||
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
|
||||
|
|
@ -139,11 +135,6 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
item {
|
||||
var showLanguageSheet by remember { mutableStateOf(false) }
|
||||
var showDesktopNavigationSheet by remember { mutableStateOf(false) }
|
||||
val desktopNavigationLayout by remember {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
ThemeSettingsRepository.desktopNavigationLayout
|
||||
}.collectAsStateWithLifecycle()
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_appearance_section_display),
|
||||
isTablet = isTablet,
|
||||
|
|
@ -166,16 +157,6 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
onCheckedChange = onLiquidGlassNativeTabBarToggle,
|
||||
)
|
||||
}
|
||||
if (isDesktop) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_appearance_desktop_navigation),
|
||||
description = stringResource(desktopNavigationLayout.labelRes),
|
||||
icon = Icons.Rounded.Style,
|
||||
isTablet = isTablet,
|
||||
onClick = { showDesktopNavigationSheet = true },
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_appearance_app_language),
|
||||
|
|
@ -187,17 +168,6 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
}
|
||||
|
||||
if (showDesktopNavigationSheet) {
|
||||
DesktopNavigationLayoutBottomSheet(
|
||||
selectedLayout = desktopNavigationLayout,
|
||||
onLayoutSelected = {
|
||||
ThemeSettingsRepository.setDesktopNavigationLayout(it)
|
||||
showDesktopNavigationSheet = false
|
||||
},
|
||||
onDismiss = { showDesktopNavigationSheet = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showLanguageSheet) {
|
||||
AppearanceLanguageBottomSheet(
|
||||
selectedLanguage = selectedAppLanguage,
|
||||
|
|
@ -236,66 +206,6 @@ internal fun LazyListScope.appearanceSettingsContent(
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DesktopNavigationLayoutBottomSheet(
|
||||
selectedLayout: DesktopNavigationLayout,
|
||||
onLayoutSelected: (DesktopNavigationLayout) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NuvioModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_appearance_desktop_navigation_sheet_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(DesktopNavigationLayout.entries) { index, layout ->
|
||||
if (index > 0) {
|
||||
NuvioBottomSheetDivider()
|
||||
}
|
||||
NuvioBottomSheetActionRow(
|
||||
title = stringResource(layout.labelRes),
|
||||
onClick = {
|
||||
onLayoutSelected(layout)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
if (layout == selectedLayout) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.cd_selected),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AppLanguageSheetOption(
|
||||
val language: AppLanguage,
|
||||
val labelRes: StringResource,
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_sidebar
|
||||
import nuvio.composeapp.generated.resources.settings_appearance_desktop_navigation_top_bar
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
enum class DesktopNavigationLayout(
|
||||
val labelRes: StringResource,
|
||||
) {
|
||||
Sidebar(Res.string.settings_appearance_desktop_navigation_sidebar),
|
||||
TopBar(Res.string.settings_appearance_desktop_navigation_top_bar),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val Default = Sidebar
|
||||
|
||||
fun fromName(name: String?): DesktopNavigationLayout =
|
||||
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: Default
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalUriHandler
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.features.cloud.PremiumizeCloudLibraryPosterUrl
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ internal fun LazyListScope.playbackSettingsContent(
|
|||
showLoadingOverlay: Boolean,
|
||||
holdToSpeedEnabled: Boolean,
|
||||
holdToSpeedValue: Float,
|
||||
touchGesturesEnabled: Boolean,
|
||||
preferredAudioLanguage: String,
|
||||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
|
|
@ -109,6 +110,7 @@ internal fun LazyListScope.playbackSettingsContent(
|
|||
showLoadingOverlay = showLoadingOverlay,
|
||||
holdToSpeedEnabled = holdToSpeedEnabled,
|
||||
holdToSpeedValue = holdToSpeedValue,
|
||||
touchGesturesEnabled = touchGesturesEnabled,
|
||||
preferredAudioLanguage = preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
|
|
@ -243,6 +245,7 @@ private fun PlaybackSettingsSection(
|
|||
showLoadingOverlay: Boolean,
|
||||
holdToSpeedEnabled: Boolean,
|
||||
holdToSpeedValue: Float,
|
||||
touchGesturesEnabled: Boolean,
|
||||
preferredAudioLanguage: String,
|
||||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
|
|
@ -351,6 +354,15 @@ private fun PlaybackSettingsSection(
|
|||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_touch_gestures),
|
||||
description = stringResource(Res.string.settings_playback_touch_gestures_description),
|
||||
checked = touchGesturesEnabled,
|
||||
enabled = !autoPlayPlayerSettings.externalPlayerEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setTouchGesturesEnabled,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_hold_to_speed),
|
||||
description = stringResource(Res.string.settings_playback_hold_to_speed_description),
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.build.AppVersionPolicy
|
||||
import com.nuvio.app.core.build.AppVersionConfig
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_about_based_on_version_format
|
||||
import nuvio.composeapp.generated.resources.compose_about_made_with
|
||||
import nuvio.composeapp.generated.resources.compose_about_version_format
|
||||
import nuvio.composeapp.generated.resources.compose_settings_page_account
|
||||
|
|
@ -76,8 +75,6 @@ internal fun LazyListScope.settingsRootContent(
|
|||
onDownloadsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onSwitchProfileClick: (() -> Unit)? = null,
|
||||
showDownloadsEntry: Boolean = true,
|
||||
showNotificationsEntry: Boolean = true,
|
||||
showAccountSection: Boolean = true,
|
||||
showGeneralSection: Boolean = true,
|
||||
showAboutSection: Boolean = true,
|
||||
|
|
@ -141,16 +138,14 @@ internal fun LazyListScope.settingsRootContent(
|
|||
isTablet = isTablet,
|
||||
onClick = onContentDiscoveryClick,
|
||||
)
|
||||
if (showDownloadsEntry) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_root_downloads_title),
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
isTablet = isTablet,
|
||||
onClick = onDownloadsClick,
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_root_downloads_title),
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
isTablet = isTablet,
|
||||
onClick = onDownloadsClick,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_playback),
|
||||
|
|
@ -175,16 +170,14 @@ internal fun LazyListScope.settingsRootContent(
|
|||
isTablet = isTablet,
|
||||
onClick = onIntegrationsClick,
|
||||
)
|
||||
if (showNotificationsEntry) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_notifications),
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
isTablet = isTablet,
|
||||
onClick = onNotificationsClick,
|
||||
)
|
||||
}
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.compose_settings_page_notifications),
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
isTablet = isTablet,
|
||||
onClick = onNotificationsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -259,26 +252,14 @@ internal fun LazyListScope.settingsRootContent(
|
|||
Text(
|
||||
text = stringResource(
|
||||
Res.string.compose_about_version_format,
|
||||
AppVersionPolicy.displayVersionName,
|
||||
AppVersionPolicy.displayVersionCode,
|
||||
AppVersionConfig.VERSION_NAME,
|
||||
AppVersionConfig.VERSION_CODE,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
AppVersionPolicy.basedOnVersionName?.let { basedOnVersionName ->
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.compose_about_based_on_version_format,
|
||||
basedOnVersionName,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,12 +217,6 @@ fun SettingsScreen(
|
|||
val page = remember(currentPage) { SettingsPage.valueOf(currentPage) }
|
||||
val previousPage = page.previousPage()
|
||||
|
||||
LaunchedEffect(page) {
|
||||
if (!page.isEnabledByFeaturePolicy()) {
|
||||
currentPage = SettingsPage.Root.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rootActionRequests, rootActionsEnabled, page) {
|
||||
rootActionRequests.collect {
|
||||
if (!rootActionsEnabled) return@collect
|
||||
|
|
@ -240,9 +234,7 @@ fun SettingsScreen(
|
|||
?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() }
|
||||
?: return@LaunchedEffect
|
||||
if (!rootActionsEnabled) return@LaunchedEffect
|
||||
if (targetPage.isEnabledByFeaturePolicy()) {
|
||||
currentPage = targetPage.name
|
||||
}
|
||||
currentPage = targetPage.name
|
||||
onRequestedPageConsumed()
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +251,7 @@ fun SettingsScreen(
|
|||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
|
||||
holdToSpeedValue = playerSettingsUiState.holdToSpeedValue,
|
||||
touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled,
|
||||
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||
|
|
@ -309,6 +302,7 @@ fun SettingsScreen(
|
|||
showLoadingOverlay = playerSettingsUiState.showLoadingOverlay,
|
||||
holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled,
|
||||
holdToSpeedValue = playerSettingsUiState.holdToSpeedValue,
|
||||
touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled,
|
||||
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||
|
|
@ -369,6 +363,7 @@ private fun MobileSettingsScreen(
|
|||
showLoadingOverlay: Boolean,
|
||||
holdToSpeedEnabled: Boolean,
|
||||
holdToSpeedValue: Float,
|
||||
touchGesturesEnabled: Boolean,
|
||||
preferredAudioLanguage: String,
|
||||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
|
|
@ -440,8 +435,6 @@ private fun MobileSettingsScreen(
|
|||
}
|
||||
val searchEntries = settingsSearchEntries(
|
||||
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||
downloadsEnabled = AppFeaturePolicy.downloadsEnabled,
|
||||
notificationsEnabled = AppFeaturePolicy.notificationsEnabled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
switchProfileAvailable = onSwitchProfile != null,
|
||||
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||
|
|
@ -464,11 +457,7 @@ private fun MobileSettingsScreen(
|
|||
SettingsPage.MetaScreen -> onMetaScreenClick()
|
||||
else -> onPageChange(target.page)
|
||||
}
|
||||
SettingsSearchTarget.Downloads -> {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
onDownloadsClick()
|
||||
}
|
||||
}
|
||||
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||
|
|
@ -528,8 +517,6 @@ private fun MobileSettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showDownloadsEntry = AppFeaturePolicy.downloadsEnabled,
|
||||
showNotificationsEntry = AppFeaturePolicy.notificationsEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -547,6 +534,7 @@ private fun MobileSettingsScreen(
|
|||
showLoadingOverlay = showLoadingOverlay,
|
||||
holdToSpeedEnabled = holdToSpeedEnabled,
|
||||
holdToSpeedValue = holdToSpeedValue,
|
||||
touchGesturesEnabled = touchGesturesEnabled,
|
||||
preferredAudioLanguage = preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
|
|
@ -580,12 +568,10 @@ private fun MobileSettingsScreen(
|
|||
isTablet = false,
|
||||
rememberLastProfileEnabled = rememberLastProfileEnabled,
|
||||
)
|
||||
SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) {
|
||||
notificationsSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
}
|
||||
SettingsPage.Notifications -> notificationsSettingsContent(
|
||||
isTablet = false,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
|
||||
isTablet = false,
|
||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
|
|
@ -653,13 +639,6 @@ private fun MobileSettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
private fun SettingsPage.isEnabledByFeaturePolicy(): Boolean =
|
||||
when (this) {
|
||||
SettingsPage.Notifications -> AppFeaturePolicy.notificationsEnabled
|
||||
SettingsPage.Plugins -> AppFeaturePolicy.pluginsEnabled
|
||||
else -> true
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSettingsRootSearchRevealConnection(
|
||||
page: SettingsPage,
|
||||
|
|
@ -710,6 +689,7 @@ private fun TabletSettingsScreen(
|
|||
showLoadingOverlay: Boolean,
|
||||
holdToSpeedEnabled: Boolean,
|
||||
holdToSpeedValue: Float,
|
||||
touchGesturesEnabled: Boolean,
|
||||
preferredAudioLanguage: String,
|
||||
secondaryPreferredAudioLanguage: String?,
|
||||
preferredSubtitleLanguage: String,
|
||||
|
|
@ -820,8 +800,6 @@ private fun TabletSettingsScreen(
|
|||
val hapticScope = rememberCoroutineScope()
|
||||
val searchEntries = settingsSearchEntries(
|
||||
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||
downloadsEnabled = AppFeaturePolicy.downloadsEnabled,
|
||||
notificationsEnabled = AppFeaturePolicy.notificationsEnabled,
|
||||
liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported,
|
||||
switchProfileAvailable = onSwitchProfile != null,
|
||||
checkForUpdatesAvailable = onCheckForUpdatesClick != null,
|
||||
|
|
@ -829,16 +807,8 @@ private fun TabletSettingsScreen(
|
|||
|
||||
fun openSearchTarget(target: SettingsSearchTarget) {
|
||||
when (target) {
|
||||
is SettingsSearchTarget.Page -> {
|
||||
if (target.page.isEnabledByFeaturePolicy()) {
|
||||
openInlinePage(target.page)
|
||||
}
|
||||
}
|
||||
SettingsSearchTarget.Downloads -> {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
onDownloadsClick()
|
||||
}
|
||||
}
|
||||
is SettingsSearchTarget.Page -> openInlinePage(target.page)
|
||||
SettingsSearchTarget.Downloads -> onDownloadsClick()
|
||||
SettingsSearchTarget.Collections -> onCollectionsClick()
|
||||
SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke()
|
||||
SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke()
|
||||
|
|
@ -928,8 +898,6 @@ private fun TabletSettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = { openInlinePage(SettingsPage.Account) },
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showDownloadsEntry = AppFeaturePolicy.downloadsEnabled,
|
||||
showNotificationsEntry = AppFeaturePolicy.notificationsEnabled,
|
||||
showAccountSection = activeCategory == SettingsCategory.Account,
|
||||
showGeneralSection = activeCategory == SettingsCategory.General,
|
||||
showAboutSection = activeCategory == SettingsCategory.About,
|
||||
|
|
@ -951,6 +919,7 @@ private fun TabletSettingsScreen(
|
|||
showLoadingOverlay = showLoadingOverlay,
|
||||
holdToSpeedEnabled = holdToSpeedEnabled,
|
||||
holdToSpeedValue = holdToSpeedValue,
|
||||
touchGesturesEnabled = touchGesturesEnabled,
|
||||
preferredAudioLanguage = preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage,
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
|
|
@ -984,12 +953,10 @@ private fun TabletSettingsScreen(
|
|||
isTablet = true,
|
||||
rememberLastProfileEnabled = rememberLastProfileEnabled,
|
||||
)
|
||||
SettingsPage.Notifications -> if (AppFeaturePolicy.notificationsEnabled) {
|
||||
notificationsSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
}
|
||||
SettingsPage.Notifications -> notificationsSettingsContent(
|
||||
isTablet = true,
|
||||
uiState = episodeReleaseNotificationsUiState,
|
||||
)
|
||||
SettingsPage.ContinueWatching -> continueWatchingSettingsContent(
|
||||
isTablet = true,
|
||||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
|
|
|
|||
|
|
@ -79,8 +79,6 @@ internal data class SettingsSearchEntry(
|
|||
@Composable
|
||||
internal fun settingsSearchEntries(
|
||||
pluginsEnabled: Boolean,
|
||||
downloadsEnabled: Boolean,
|
||||
notificationsEnabled: Boolean,
|
||||
liquidGlassNativeTabBarSupported: Boolean,
|
||||
switchProfileAvailable: Boolean,
|
||||
checkForUpdatesAvailable: Boolean,
|
||||
|
|
@ -227,16 +225,14 @@ internal fun settingsSearchEntries(
|
|||
description = stringResource(Res.string.compose_settings_root_content_discovery_description),
|
||||
icon = Icons.Rounded.Extension,
|
||||
)
|
||||
if (downloadsEnabled) {
|
||||
add(
|
||||
key = "downloads",
|
||||
title = downloadsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
category = generalCategory,
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
target = SettingsSearchTarget.Downloads,
|
||||
)
|
||||
}
|
||||
add(
|
||||
key = "downloads",
|
||||
title = downloadsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_downloads_description),
|
||||
category = generalCategory,
|
||||
icon = Icons.Rounded.CloudDownload,
|
||||
target = SettingsSearchTarget.Downloads,
|
||||
)
|
||||
addPage(
|
||||
page = SettingsPage.Playback,
|
||||
key = "playback",
|
||||
|
|
@ -258,15 +254,13 @@ internal fun settingsSearchEntries(
|
|||
description = stringResource(Res.string.compose_settings_root_integrations_description),
|
||||
icon = Icons.Rounded.Link,
|
||||
)
|
||||
if (notificationsEnabled) {
|
||||
addPage(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notifications",
|
||||
title = notificationsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
}
|
||||
addPage(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notifications",
|
||||
title = notificationsPage,
|
||||
description = stringResource(Res.string.compose_settings_root_notifications_description),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
addPage(
|
||||
page = SettingsPage.SupportersContributors,
|
||||
key = "supporters",
|
||||
|
|
@ -530,6 +524,11 @@ internal fun settingsSearchEntries(
|
|||
stringResource(Res.string.settings_playback_hold_to_speed),
|
||||
stringResource(Res.string.settings_playback_hold_to_speed_description),
|
||||
),
|
||||
PlaybackSearchRow(
|
||||
"touch-gestures",
|
||||
stringResource(Res.string.settings_playback_touch_gestures),
|
||||
stringResource(Res.string.settings_playback_touch_gestures_description),
|
||||
),
|
||||
PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)),
|
||||
),
|
||||
)
|
||||
|
|
@ -800,26 +799,24 @@ internal fun settingsSearchEntries(
|
|||
)
|
||||
}
|
||||
|
||||
if (notificationsEnabled) {
|
||||
val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "episode-release-alerts",
|
||||
title = stringResource(Res.string.settings_notifications_episode_release_alerts),
|
||||
description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
|
||||
pageLabel = notificationsPage,
|
||||
section = notificationsAlerts,
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notification-test",
|
||||
title = stringResource(Res.string.settings_notifications_test_title),
|
||||
pageLabel = notificationsPage,
|
||||
section = stringResource(Res.string.settings_notifications_section_test),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
}
|
||||
val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "episode-release-alerts",
|
||||
title = stringResource(Res.string.settings_notifications_episode_release_alerts),
|
||||
description = stringResource(Res.string.settings_notifications_episode_release_alerts_description),
|
||||
pageLabel = notificationsPage,
|
||||
section = notificationsAlerts,
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
addRow(
|
||||
page = SettingsPage.Notifications,
|
||||
key = "notification-test",
|
||||
title = stringResource(Res.string.settings_notifications_test_title),
|
||||
pageLabel = notificationsPage,
|
||||
section = stringResource(Res.string.settings_notifications_section_test),
|
||||
icon = Icons.Rounded.Notifications,
|
||||
)
|
||||
|
||||
addRow(
|
||||
page = SettingsPage.TraktAuthentication,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioScreenHeader
|
||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
|
|
|
|||
|
|
@ -17,9 +17,6 @@ object ThemeSettingsRepository {
|
|||
private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false)
|
||||
val liquidGlassNativeTabBarEnabled: StateFlow<Boolean> = _liquidGlassNativeTabBarEnabled.asStateFlow()
|
||||
|
||||
private val _desktopNavigationLayout = MutableStateFlow(DesktopNavigationLayout.Default)
|
||||
val desktopNavigationLayout: StateFlow<DesktopNavigationLayout> = _desktopNavigationLayout.asStateFlow()
|
||||
|
||||
private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH)
|
||||
val selectedAppLanguage: StateFlow<AppLanguage> = _selectedAppLanguage.asStateFlow()
|
||||
|
||||
|
|
@ -39,7 +36,6 @@ object ThemeSettingsRepository {
|
|||
_selectedTheme.value = AppTheme.WHITE
|
||||
_amoledEnabled.value = false
|
||||
_liquidGlassNativeTabBarEnabled.value = false
|
||||
_desktopNavigationLayout.value = DesktopNavigationLayout.Default
|
||||
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
|
||||
NativeTabBridge.publishLiquidGlassEnabled(false)
|
||||
_selectedAppLanguage.value = AppLanguage.ENGLISH
|
||||
|
|
@ -63,9 +59,6 @@ object ThemeSettingsRepository {
|
|||
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
|
||||
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
|
||||
NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled)
|
||||
_desktopNavigationLayout.value = DesktopNavigationLayout.fromName(
|
||||
ThemeSettingsStorage.loadDesktopNavigationLayout(),
|
||||
)
|
||||
val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage())
|
||||
ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code)
|
||||
_selectedAppLanguage.value = appLanguage
|
||||
|
|
@ -94,13 +87,6 @@ object ThemeSettingsRepository {
|
|||
NativeTabBridge.publishLiquidGlassEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setDesktopNavigationLayout(layout: DesktopNavigationLayout) {
|
||||
ensureLoaded()
|
||||
if (_desktopNavigationLayout.value == layout) return
|
||||
_desktopNavigationLayout.value = layout
|
||||
ThemeSettingsStorage.saveDesktopNavigationLayout(layout.name)
|
||||
}
|
||||
|
||||
fun setAppLanguage(language: AppLanguage) {
|
||||
ensureLoaded()
|
||||
if (_selectedAppLanguage.value == language) return
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue