mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Revert "Merge branch 'desktopweb' into cmp-rewrite"
This reverts commitb420dcaf71, reversing changes made tofdadc7c610.
This commit is contained in:
parent
c755ead445
commit
1c7d6acd9b
177 changed files with 567 additions and 18081 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -79,13 +79,7 @@ actual fun PlatformPlayerSurface(
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -536,10 +535,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>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.nuvio.app
|
|||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -12,9 +11,6 @@ import androidx.compose.animation.togetherWith
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.hoverable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
|
|
@ -23,18 +19,15 @@ import androidx.compose.foundation.layout.Row
|
|||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -62,10 +55,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
|
|
@ -163,15 +153,12 @@ import com.nuvio.app.features.player.prepareExternalPlayerLaunch
|
|||
import com.nuvio.app.features.player.SubtitleLanguageOption
|
||||
import com.nuvio.app.features.player.sanitizePlaybackHeaders
|
||||
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
|
||||
import com.nuvio.app.features.profiles.ActiveProfileMiniAvatar
|
||||
import com.nuvio.app.features.profiles.AvatarCatalogItem
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
import com.nuvio.app.features.profiles.NuvioProfile
|
||||
import com.nuvio.app.features.profiles.ProfileEditScreen
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.profiles.ProfileSelectionScreen
|
||||
import com.nuvio.app.features.profiles.ProfileSwitcherTab
|
||||
import com.nuvio.app.features.profiles.SidebarProfileSwitcherStack
|
||||
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||
import com.nuvio.app.features.search.SearchScreen
|
||||
import com.nuvio.app.features.settings.SettingsScreen
|
||||
|
|
@ -181,16 +168,13 @@ import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
|
|||
import com.nuvio.app.features.settings.AddonsSettingsScreen
|
||||
import com.nuvio.app.features.settings.PluginsSettingsScreen
|
||||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||
import com.nuvio.app.features.settings.DesktopNavigationLayout
|
||||
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
|
||||
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.collection.CollectionManagementScreen
|
||||
import com.nuvio.app.features.collection.CollectionEditorScreen
|
||||
import com.nuvio.app.features.collection.CollectionEditorRepository
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.collection.CollectionSyncService
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
||||
import com.nuvio.app.features.collection.FolderDetailScreen
|
||||
import com.nuvio.app.features.collection.FolderDetailRepository
|
||||
|
|
@ -208,7 +192,6 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
|
|||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
|
|
@ -221,13 +204,10 @@ import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
|||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.app_logo_wordmark
|
||||
|
|
@ -341,11 +321,6 @@ enum class AppScreenTab {
|
|||
Settings,
|
||||
}
|
||||
|
||||
private val DesktopSidebarCollapsedWidth = 76.dp
|
||||
private val DesktopSidebarExpandedWidth = 184.dp
|
||||
private val DesktopSidebarExpandedContentWidth = 144.dp
|
||||
private val DesktopSidebarIconSlotSize = 36.dp
|
||||
|
||||
private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) {
|
||||
AppScreenTab.Home -> NativeNavigationTab.Home
|
||||
AppScreenTab.Search -> NativeNavigationTab.Search
|
||||
|
|
@ -376,37 +351,10 @@ private enum class AppGateScreen {
|
|||
Loading,
|
||||
Auth,
|
||||
ProfileSelection,
|
||||
ProfileSwitching,
|
||||
ProfileEdit,
|
||||
Main,
|
||||
}
|
||||
|
||||
private data class PendingProfileSwitch(
|
||||
val profile: NuvioProfile,
|
||||
val syncOnEnter: Boolean,
|
||||
)
|
||||
|
||||
private suspend fun warmProfileBoundRepositories() {
|
||||
withContext(Dispatchers.Default) {
|
||||
AddonRepository.initialize()
|
||||
CollectionRepository.initialize()
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
DownloadsRepository.ensureLoaded()
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
LibraryRepository.ensureLoaded()
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
CollectionSyncService.startObserving()
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
|
|
@ -469,7 +417,6 @@ fun App() {
|
|||
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
|
||||
var isNewProfile by remember { mutableStateOf(false) }
|
||||
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingProfileSwitch by remember { mutableStateOf<PendingProfileSwitch?>(null) }
|
||||
|
||||
fun rememberedStartupProfile(profiles: List<NuvioProfile>): NuvioProfile? {
|
||||
val currentProfileState = ProfileRepository.state.value
|
||||
|
|
@ -485,12 +432,6 @@ fun App() {
|
|||
?.takeUnless { it.pinEnabled }
|
||||
}
|
||||
|
||||
fun requestProfileSwitch(profile: NuvioProfile, syncOnEnter: Boolean) {
|
||||
autoSkipProfileSelection = false
|
||||
pendingProfileSwitch = PendingProfileSwitch(profile, syncOnEnter)
|
||||
gateScreen = AppGateScreen.ProfileSwitching.name
|
||||
}
|
||||
|
||||
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
|
||||
if (profiles.isEmpty()) {
|
||||
autoSkipProfileSelection = true
|
||||
|
|
@ -499,7 +440,12 @@ fun App() {
|
|||
}
|
||||
|
||||
rememberedStartupProfile(profiles)?.let { profile ->
|
||||
requestProfileSwitch(profile, syncOnEnter)
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
if (syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -510,40 +456,18 @@ fun App() {
|
|||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
return
|
||||
}
|
||||
requestProfileSwitch(onlyProfile, syncOnEnter)
|
||||
ProfileRepository.selectProfile(onlyProfile.profileIndex)
|
||||
if (syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
} else {
|
||||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(gateScreen, pendingProfileSwitch) {
|
||||
if (gateScreen == AppGateScreen.ProfileSwitching.name && pendingProfileSwitch == null) {
|
||||
gateScreen = AppGateScreen.Loading.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingProfileSwitch) {
|
||||
val request = pendingProfileSwitch ?: return@LaunchedEffect
|
||||
runCatching {
|
||||
ProfileRepository.switchToProfile(request.profile.profileIndex)
|
||||
warmProfileBoundRepositories()
|
||||
if (request.syncOnEnter) {
|
||||
SyncManager.pullAllForProfile(request.profile.profileIndex)
|
||||
}
|
||||
}.onSuccess {
|
||||
pendingProfileSwitch = null
|
||||
autoSkipProfileSelection = false
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
}.onFailure {
|
||||
pendingProfileSwitch = null
|
||||
autoSkipProfileSelection = false
|
||||
gateScreen = AppGateScreen.ProfileSelection.name
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
|
||||
if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect
|
||||
|
||||
val cachedProfiles = profileState.profiles
|
||||
val hasCachedProfileAccess =
|
||||
cachedProfiles.isNotEmpty() &&
|
||||
|
|
@ -601,10 +525,10 @@ fun App() {
|
|||
gateScreen == AppGateScreen.ProfileSelection.name
|
||||
) {
|
||||
rememberedStartupProfile(profileState.profiles)?.let { profile ->
|
||||
requestProfileSwitch(
|
||||
profile = profile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
|
|
@ -613,10 +537,10 @@ fun App() {
|
|||
val onlyProfile = profileState.profiles.first()
|
||||
if (onlyProfile.pinEnabled) return@LaunchedEffect
|
||||
|
||||
requestProfileSwitch(
|
||||
profile = onlyProfile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
ProfileRepository.selectProfile(onlyProfile.profileIndex)
|
||||
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
autoSkipProfileSelection = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -629,9 +553,15 @@ fun App() {
|
|||
},
|
||||
) { currentGate ->
|
||||
when (currentGate) {
|
||||
AppGateScreen.Loading.name,
|
||||
AppGateScreen.ProfileSwitching.name -> {
|
||||
AppLaunchOverlay(modifier = Modifier.fillMaxSize())
|
||||
AppGateScreen.Loading.name -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.nuvio.colors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent)
|
||||
}
|
||||
}
|
||||
AppGateScreen.Auth.name -> {
|
||||
AuthScreen(modifier = Modifier.fillMaxSize())
|
||||
|
|
@ -644,10 +574,11 @@ fun App() {
|
|||
}
|
||||
ProfileSelectionScreen(
|
||||
onProfileSelected = { profile ->
|
||||
requestProfileSwitch(
|
||||
profile = profile,
|
||||
syncOnEnter = authState is AuthState.Authenticated,
|
||||
)
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
if (authState is AuthState.Authenticated) {
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
}
|
||||
gateScreen = AppGateScreen.Main.name
|
||||
},
|
||||
onEditProfile = { profile ->
|
||||
editingProfile = profile
|
||||
|
|
@ -693,6 +624,18 @@ private fun MainAppContent(
|
|||
) {
|
||||
val navController = rememberNavController()
|
||||
val appUpdaterController = rememberAppUpdaterController()
|
||||
remember {
|
||||
EpisodeReleaseNotificationsRepository.ensureLoaded()
|
||||
}
|
||||
remember {
|
||||
CollectionSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
HomeCatalogSettingsSyncService.startObserving()
|
||||
}
|
||||
remember {
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }
|
||||
|
|
@ -701,17 +644,10 @@ private fun MainAppContent(
|
|||
val searchScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
warmProfileBoundRepositories()
|
||||
}
|
||||
val currentBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val liquidGlassNativeTabBarEnabled by remember {
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
|
||||
}.collectAsStateWithLifecycle()
|
||||
val desktopNavigationLayout by remember {
|
||||
ThemeSettingsRepository.desktopNavigationLayout
|
||||
}.collectAsStateWithLifecycle()
|
||||
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
|
||||
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
|
||||
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
|
||||
|
|
@ -724,14 +660,32 @@ private fun MainAppContent(
|
|||
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember { mutableStateOf(false) }
|
||||
var pickerError by remember { mutableStateOf<String?>(null) }
|
||||
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
|
||||
val libraryUiState by LibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val addonsUiState by remember {
|
||||
AddonRepository.initialize()
|
||||
AddonRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val libraryUiState by remember {
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val p2pSettingsUiState by P2pSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val downloadsUiState by DownloadsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val p2pSettingsUiState by remember {
|
||||
P2pSettingsRepository.ensureLoaded()
|
||||
P2pSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val watchedUiState by remember {
|
||||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val downloadsUiState by remember {
|
||||
DownloadsRepository.ensureLoaded()
|
||||
DownloadsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by remember {
|
||||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
|
@ -877,7 +831,6 @@ private fun MainAppContent(
|
|||
NetworkCondition.ServersUnreachable,
|
||||
-> {
|
||||
offlineLaunchRouteHandled = true
|
||||
if (!AppFeaturePolicy.downloadsEnabled) return@LaunchedEffect
|
||||
val hasPlayableDownload = downloadsUiState.completedItems.any {
|
||||
DownloadsRepository.playableLocalFileUri(it) != null
|
||||
}
|
||||
|
|
@ -965,7 +918,10 @@ private fun MainAppContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val continueWatchingPreferencesUiState by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferencesUiState by remember {
|
||||
ContinueWatchingPreferencesRepository.ensureLoaded()
|
||||
ContinueWatchingPreferencesRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(
|
||||
initialHomeReady,
|
||||
|
|
@ -1000,11 +956,9 @@ private fun MainAppContent(
|
|||
}
|
||||
|
||||
AppDeepLink.Downloads -> {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
selectedTab = AppScreenTab.Settings
|
||||
navController.navigate(DownloadsSettingsRoute) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
AppDeepLinkRepository.markConsumed(deepLink)
|
||||
}
|
||||
|
|
@ -1131,7 +1085,7 @@ private fun MainAppContent(
|
|||
val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L)
|
||||
val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction
|
||||
|
||||
if (!manualSelection && AppFeaturePolicy.downloadsEnabled) {
|
||||
if (!manualSelection) {
|
||||
val downloadedItem = DownloadsRepository.findPlayableDownload(
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = seasonNumber,
|
||||
|
|
@ -1388,31 +1342,12 @@ private fun MainAppContent(
|
|||
val isTabletLayout = maxWidth >= 768.dp
|
||||
val useNativeBottomTabs =
|
||||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||
val useDesktopSidebar = isDesktop &&
|
||||
isTabletLayout &&
|
||||
!useNativeBottomTabs &&
|
||||
desktopNavigationLayout == DesktopNavigationLayout.Sidebar
|
||||
val useFloatingTopBar = isTabletLayout && !useNativeBottomTabs && !useDesktopSidebar
|
||||
val topChromePadding = if (useFloatingTopBar) {
|
||||
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
max(statusBarPadding + 24.dp, 48.dp) + 64.dp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
|
||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||
profileSwitchLoading = true
|
||||
selectedTab = AppScreenTab.Home
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
ProfileRepository.switchToProfile(profile.profileIndex)
|
||||
warmProfileBoundRepositories()
|
||||
SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
delay(300)
|
||||
} finally {
|
||||
profileSwitchLoading = false
|
||||
}
|
||||
}
|
||||
ProfileRepository.selectProfile(profile.profileIndex)
|
||||
com.nuvio.app.core.sync.SyncManager.pullAllForProfile(profile.profileIndex)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -1464,10 +1399,8 @@ private fun MainAppContent(
|
|||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(start = if (useDesktopSidebar) DesktopSidebarCollapsedWidth else 0.dp),
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
topChromePadding = topChromePadding,
|
||||
searchFocusRequestCount = searchFocusRequestCount,
|
||||
rootActionsEnabled = tabsRouteActive,
|
||||
homeScrollToTopRequests = homeScrollToTopRequests,
|
||||
|
|
@ -1523,11 +1456,7 @@ private fun MainAppContent(
|
|||
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
|
||||
onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) },
|
||||
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
|
||||
onDownloadsSettingsClick = {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
navController.navigate(DownloadsSettingsRoute)
|
||||
}
|
||||
},
|
||||
onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) },
|
||||
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
|
||||
onPluginsSettingsClick = {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
|
|
@ -1563,14 +1492,7 @@ private fun MainAppContent(
|
|||
)
|
||||
}
|
||||
|
||||
if (useDesktopSidebar) {
|
||||
DesktopHoverSidebar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = ::handleRootTabClick,
|
||||
onProfileSelected = onProfileSelected,
|
||||
onAddProfileRequested = onSwitchProfile,
|
||||
)
|
||||
} else if (useFloatingTopBar) {
|
||||
if (isTabletLayout && !useNativeBottomTabs) {
|
||||
TabletFloatingTopBar(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = ::handleRootTabClick,
|
||||
|
|
@ -1803,7 +1725,10 @@ private fun MainAppContent(
|
|||
hasResolvedVideoId = true
|
||||
}
|
||||
|
||||
val playerSettings by PlayerSettingsRepository.uiState.collectAsStateWithLifecycle()
|
||||
val playerSettings by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
|
||||
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
|
||||
|
|
@ -2512,53 +2437,51 @@ private fun MainAppContent(
|
|||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
composable<DownloadsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
backStackEntry = backStackEntry,
|
||||
)
|
||||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
composable<DownloadsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
navController = navController,
|
||||
backStackEntry = backStackEntry,
|
||||
)
|
||||
DownloadsScreen(
|
||||
onBack = onBack,
|
||||
onOpenDownload = { item ->
|
||||
val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen
|
||||
val resumeEntry = item.videoId
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(WatchProgressRepository::progressForVideo)
|
||||
?.takeIf { it.isResumable }
|
||||
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamTitle = item.streamTitle,
|
||||
streamSubtitle = item.streamSubtitle,
|
||||
providerName = item.providerName,
|
||||
providerAddonId = item.providerAddonId,
|
||||
contentType = item.contentType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
coroutineScope.launch { openExternalPlayback(playerLaunch) }
|
||||
return@DownloadsScreen
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
}
|
||||
val playerLaunch = PlayerLaunch(
|
||||
title = item.title,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceHeaders = emptyMap(),
|
||||
sourceResponseHeaders = emptyMap(),
|
||||
logo = item.logo,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
seasonNumber = item.seasonNumber,
|
||||
episodeNumber = item.episodeNumber,
|
||||
episodeTitle = item.episodeTitle,
|
||||
episodeThumbnail = item.episodeThumbnail,
|
||||
streamTitle = item.streamTitle,
|
||||
streamSubtitle = item.streamSubtitle,
|
||||
providerName = item.providerName,
|
||||
providerAddonId = item.providerAddonId,
|
||||
contentType = item.contentType,
|
||||
videoId = item.videoId,
|
||||
parentMetaId = item.parentMetaId,
|
||||
parentMetaType = item.parentMetaType,
|
||||
initialPositionMs = resumeEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L,
|
||||
initialProgressFraction = resumeEntry?.progressFraction?.takeIf { it > 0f },
|
||||
)
|
||||
if (playerSettingsUiState.externalPlayerEnabled) {
|
||||
coroutineScope.launch { openExternalPlayback(playerLaunch) }
|
||||
return@DownloadsScreen
|
||||
}
|
||||
val launchId = PlayerLaunchStore.put(playerLaunch)
|
||||
navController.navigate(PlayerRoute(launchId = launchId))
|
||||
},
|
||||
)
|
||||
}
|
||||
composable<AddonsSettingsRoute> { backStackEntry ->
|
||||
val onBack = rememberGuardedPopBackStack(
|
||||
|
|
@ -2825,6 +2748,15 @@ private fun MainAppContent(
|
|||
AppLaunchOverlay(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
// Auto-dismiss profile switch overlay
|
||||
if (profileSwitchLoading) {
|
||||
LaunchedEffect(Unit) {
|
||||
// Brief loading screen while home refreshes for the new profile
|
||||
kotlinx.coroutines.delay(1200)
|
||||
profileSwitchLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
NuvioFloatingPrompt(
|
||||
visible = resumePromptItem != null,
|
||||
imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl,
|
||||
|
|
@ -2882,7 +2814,6 @@ private fun rememberGuardedPopBackStack(
|
|||
private fun AppTabHost(
|
||||
selectedTab: AppScreenTab,
|
||||
modifier: Modifier = Modifier,
|
||||
topChromePadding: Dp? = null,
|
||||
searchFocusRequestCount: Int = 0,
|
||||
rootActionsEnabled: Boolean = true,
|
||||
homeScrollToTopRequests: Flow<Unit>,
|
||||
|
|
@ -2940,7 +2871,6 @@ private fun AppTabHost(
|
|||
AppScreenTab.Search -> {
|
||||
SearchScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topChromePadding = topChromePadding,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
searchFocusRequestCount = searchFocusRequestCount,
|
||||
|
|
@ -2951,7 +2881,6 @@ private fun AppTabHost(
|
|||
AppScreenTab.Library -> {
|
||||
LibraryScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topChromePadding = topChromePadding,
|
||||
scrollToTopRequests = libraryScrollToTopRequests,
|
||||
onPosterClick = onLibraryPosterClick,
|
||||
onPosterLongClick = onLibraryPosterLongClick,
|
||||
|
|
@ -2987,260 +2916,6 @@ private fun AppTabHost(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopHoverSidebar(
|
||||
selectedTab: AppScreenTab,
|
||||
onTabSelected: (AppScreenTab) -> Unit,
|
||||
onProfileSelected: (NuvioProfile) -> Unit,
|
||||
onAddProfileRequested: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
|
||||
val activeProfile = profileState.activeProfile
|
||||
val activeProfileName = activeProfile?.name ?: stringResource(Res.string.compose_nav_profile)
|
||||
val hoverSource = remember { MutableInteractionSource() }
|
||||
val hovered by hoverSource.collectIsHoveredAsState()
|
||||
var profileStackVisible by remember { mutableStateOf(false) }
|
||||
val sidebarExpanded = hovered || profileStackVisible
|
||||
val profileTopPadding = statusBarPadding + 18.dp
|
||||
fun selectTab(tab: AppScreenTab) {
|
||||
profileStackVisible = false
|
||||
onTabSelected(tab)
|
||||
}
|
||||
val sidebarWidth by animateDpAsState(
|
||||
targetValue = if (sidebarExpanded) DesktopSidebarExpandedWidth else DesktopSidebarCollapsedWidth,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "desktop_sidebar_width",
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(sidebarWidth)
|
||||
.fillMaxHeight()
|
||||
.hoverable(hoverSource)
|
||||
.zIndex(NuvioTokens.Z.navigation),
|
||||
color = tokens.colors.background,
|
||||
contentColor = tokens.colors.textPrimary,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = profileTopPadding)
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { profileStackVisible = !profileStackVisible },
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
DesktopSidebarProfileTrigger(
|
||||
profile = activeProfile,
|
||||
avatars = avatars,
|
||||
label = activeProfileName,
|
||||
expanded = sidebarExpanded,
|
||||
)
|
||||
}
|
||||
|
||||
if (profileStackVisible) {
|
||||
SidebarProfileSwitcherStack(
|
||||
onProfileSelected = onProfileSelected,
|
||||
onAddProfileRequested = onAddProfileRequested,
|
||||
onDismissRequest = { profileStackVisible = false },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = profileTopPadding + 58.dp)
|
||||
.width(DesktopSidebarExpandedContentWidth),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_home),
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Home) },
|
||||
) { color ->
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Home,
|
||||
contentDescription = stringResource(Res.string.compose_nav_home),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_search),
|
||||
selected = selectedTab == AppScreenTab.Search,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Search) },
|
||||
) { color ->
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.sidebar_search),
|
||||
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_nav_library),
|
||||
selected = selectedTab == AppScreenTab.Library,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Library) },
|
||||
) { color ->
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.sidebar_library),
|
||||
contentDescription = stringResource(Res.string.compose_nav_library),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
DesktopSidebarItem(
|
||||
label = stringResource(Res.string.compose_settings_page_root),
|
||||
selected = selectedTab == AppScreenTab.Settings,
|
||||
expanded = sidebarExpanded,
|
||||
onClick = { selectTab(AppScreenTab.Settings) },
|
||||
) { color ->
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(Res.string.compose_settings_page_root),
|
||||
modifier = Modifier.size(NuvioTokens.Space.s20),
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSidebarProfileTrigger(
|
||||
profile: NuvioProfile?,
|
||||
avatars: List<AvatarCatalogItem>,
|
||||
label: String,
|
||||
expanded: Boolean,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(
|
||||
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(DesktopSidebarIconSlotSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ActiveProfileMiniAvatar(
|
||||
profile = profile,
|
||||
avatars = avatars,
|
||||
selected = false,
|
||||
size = 28,
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopSidebarItem(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
icon: @Composable (Color) -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted
|
||||
val iconColor = if (selected) tokens.colors.onAccent else contentColor
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clickable(onClick = onClick),
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(
|
||||
if (expanded) DesktopSidebarExpandedContentWidth else DesktopSidebarIconSlotSize,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(DesktopSidebarIconSlotSize),
|
||||
color = if (selected) tokens.colors.accent else Color.Transparent,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon(iconColor)
|
||||
}
|
||||
}
|
||||
if (expanded) {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabletFloatingTopBar(
|
||||
selectedTab: AppScreenTab,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
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
|
||||
|
|
@ -32,18 +28,15 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
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 +64,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 +78,6 @@ fun <T> NuvioShelfSection(
|
|||
)
|
||||
}
|
||||
LazyRow(
|
||||
state = rowState,
|
||||
modifier = Modifier.desktopShelfDragScroll(rowState),
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
) {
|
||||
|
|
@ -107,47 +97,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 +135,7 @@ fun NuvioPosterCard(
|
|||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
NuvioAsyncImage(
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
|
|
@ -211,7 +160,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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -781,7 +781,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 +815,6 @@ fun MetaDetailsScreen(
|
|||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
scrollOffset = heroScrollOffset,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
heroTrailerSourceUrl = heroTrailerSourceUrl,
|
||||
|
|
|
|||
|
|
@ -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,7 +40,6 @@ 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 org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -108,7 +107,6 @@ fun DetailActionButtons(
|
|||
onLongClick = onPlayLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.secondaryClick(onPlayLongClick)
|
||||
.height(buttonHeight),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -251,8 +249,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
|
||||
|
|
|
|||
|
|
@ -43,8 +43,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 +54,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 +69,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),
|
||||
|
|
@ -97,7 +95,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 +103,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 +127,8 @@ fun DetailHero(
|
|||
.graphicsLayer {
|
||||
alpha = trailerAlpha
|
||||
translationY = scrollOffset * 0.5f
|
||||
scaleX = backdropScale
|
||||
scaleY = backdropScale
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
},
|
||||
onReady = onHeroTrailerReady,
|
||||
onEnded = onHeroTrailerEnded,
|
||||
|
|
@ -237,13 +233,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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -647,7 +650,6 @@ fun HomeScreen(
|
|||
modifier = Modifier,
|
||||
viewportHeight = maxHeight,
|
||||
mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint,
|
||||
sectionPadding = if (isDesktop) homeSectionPadding else null,
|
||||
)
|
||||
|
||||
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
|
||||
|
|
@ -655,7 +657,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
|
||||
|
|
@ -48,9 +47,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 +86,6 @@ fun HomeHeroSection(
|
|||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
listState: LazyListState? = null,
|
||||
onItemClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -112,7 +108,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 +163,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 +304,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 +331,6 @@ fun HomeHeroReservedSpace(
|
|||
maxWidthDp = maxWidth.value,
|
||||
viewportHeightDp = viewportHeight?.value,
|
||||
mobileBelowSectionHeightHintDp = mobileBelowSectionHeightHint?.value,
|
||||
preferDesktopLayout = isDesktop,
|
||||
)
|
||||
|
||||
Spacer(
|
||||
|
|
@ -588,97 +408,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 +424,6 @@ internal fun homeHeroLayout(
|
|||
maxWidthDp: Float,
|
||||
viewportHeightDp: Float? = null,
|
||||
mobileBelowSectionHeightHintDp: Float? = null,
|
||||
preferDesktopLayout: Boolean = false,
|
||||
): HomeHeroLayout =
|
||||
when {
|
||||
maxWidthDp >= 1200f -> HomeHeroLayout(
|
||||
|
|
@ -728,16 +456,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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -289,13 +62,7 @@ expect fun PlatformPlayerSurface(
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -62,7 +62,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -162,33 +162,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 +173,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 +196,7 @@ private fun PlayerScreenRuntime.handleDoubleTapSeek(
|
|||
maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
|
||||
}
|
||||
}
|
||||
if (sendToController) {
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
}
|
||||
playerController?.seekTo(targetPositionMs)
|
||||
scheduleProgressSyncAfterSeek()
|
||||
showSeekFeedback(direction, nextState.amountMs)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,58 +49,6 @@ internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?)
|
|||
internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean =
|
||||
stream.needsLocalDebridResolve && stream.p2pInfoHash != null
|
||||
|
||||
internal fun StreamItem.playerSourceIdentityKey(): String? {
|
||||
p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
return "torrent:$hash:${fileIdx ?: -1}"
|
||||
}
|
||||
|
||||
clientResolve?.let { resolve ->
|
||||
val raw = resolve.stream?.raw
|
||||
val keyParts = listOf(
|
||||
addonId,
|
||||
resolve.service,
|
||||
resolve.serviceIndex?.toString(),
|
||||
resolve.infoHash?.trim()?.lowercase(),
|
||||
resolve.fileIdx?.toString(),
|
||||
resolve.magnetUri,
|
||||
resolve.torrentName,
|
||||
resolve.filename,
|
||||
raw?.torrentName,
|
||||
raw?.filename,
|
||||
raw?.size?.toString(),
|
||||
behaviorHints.filename,
|
||||
behaviorHints.videoSize?.toString(),
|
||||
streamLabel,
|
||||
streamSubtitle,
|
||||
).map { it.orEmpty().trim() }
|
||||
if (keyParts.any { it.isNotBlank() }) {
|
||||
return "resolve:${keyParts.joinToString("|")}"
|
||||
}
|
||||
}
|
||||
|
||||
behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}"
|
||||
}
|
||||
|
||||
playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url ->
|
||||
return "url:$url"
|
||||
}
|
||||
|
||||
val fallbackParts = listOf(
|
||||
addonId,
|
||||
addonName,
|
||||
streamLabel,
|
||||
streamSubtitle.orEmpty(),
|
||||
behaviorHints.filename.orEmpty(),
|
||||
behaviorHints.videoSize?.toString().orEmpty(),
|
||||
sourceName.orEmpty(),
|
||||
sources.joinToString(","),
|
||||
).map { it.trim() }
|
||||
return fallbackParts
|
||||
.takeIf { parts -> parts.any { it.isNotBlank() } }
|
||||
?.joinToString(separator = "|", prefix = "meta:")
|
||||
}
|
||||
|
||||
internal fun PlayerScreenRuntime.stopActiveP2pStream() {
|
||||
if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) {
|
||||
P2pStreamingEngine.stopStream()
|
||||
|
|
@ -168,7 +116,6 @@ internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
|
|||
activeTorrentFileIdx = stream.fileIdx
|
||||
activeTorrentFilename = stream.behaviorHints.filename
|
||||
activeTorrentTrackers = stream.p2pTrackers
|
||||
activeSourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
@ -239,11 +186,7 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
|
|||
return
|
||||
}
|
||||
val url = stream.playableDirectUrl ?: return
|
||||
val sourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
if (url == activeSourceUrl) {
|
||||
activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey
|
||||
return
|
||||
}
|
||||
if (url == activeSourceUrl) return
|
||||
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
|
||||
flushWatchProgress()
|
||||
stopActiveP2pStream()
|
||||
|
|
@ -255,7 +198,6 @@ internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
|
||||
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
|
||||
activeSourceIdentityKey = sourceIdentityKey
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
@ -329,7 +271,6 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
|
|||
activeSourceAudioUrl = null
|
||||
activeSourceHeaders = emptyMap()
|
||||
activeSourceResponseHeaders = emptyMap()
|
||||
activeSourceIdentityKey = null
|
||||
activeStreamTitle = downloadItem.streamTitle.ifBlank {
|
||||
episode.title.ifBlank { title }
|
||||
}
|
||||
|
|
@ -435,7 +376,6 @@ private fun PlayerScreenRuntime.applyEpisodeStreamMetadata(
|
|||
episode: MetaVideo,
|
||||
resume: EpisodeResume,
|
||||
) {
|
||||
activeSourceIdentityKey = stream.playerSourceIdentityKey()
|
||||
activeStreamTitle = stream.streamLabel
|
||||
activeStreamSubtitle = stream.streamSubtitle
|
||||
activeProviderName = stream.addonName
|
||||
|
|
|
|||
|
|
@ -66,8 +66,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()
|
||||
|
|
@ -100,11 +100,6 @@ internal class PlayerScreenRuntime(
|
|||
var activeTorrentFilename by mutableStateOf(torrentFilename)
|
||||
var activeTorrentTrackers by mutableStateOf(torrentTrackers)
|
||||
var p2pResolvedSourceUrl by mutableStateOf<String?>(null)
|
||||
var activeSourceIdentityKey by mutableStateOf(
|
||||
torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash ->
|
||||
"torrent:$hash:${torrentFileIdx ?: -1}"
|
||||
} ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" },
|
||||
)
|
||||
var activeStreamTitle by mutableStateOf(streamTitle)
|
||||
var activeStreamSubtitle by mutableStateOf(streamSubtitle)
|
||||
var activeProviderName by mutableStateOf(providerName)
|
||||
|
|
@ -155,12 +150,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())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -63,7 +63,6 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "sources",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -85,7 +84,6 @@ object PlayerStreamsRepository {
|
|||
forceRefresh: Boolean = false,
|
||||
) {
|
||||
fetchStreams(
|
||||
panelName = "episodeStreams",
|
||||
type = type,
|
||||
videoId = videoId,
|
||||
season = season,
|
||||
|
|
@ -121,7 +119,6 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
private fun fetchStreams(
|
||||
panelName: String,
|
||||
type: String,
|
||||
videoId: String,
|
||||
season: Int?,
|
||||
|
|
@ -140,11 +137,9 @@ object PlayerStreamsRepository {
|
|||
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 +147,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,7 +163,6 @@ object PlayerStreamsRepository {
|
|||
activeAddonIds = setOf("embedded"),
|
||||
isAnyLoading = false,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=embedded ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +183,6 @@ object PlayerStreamsRepository {
|
|||
isAnyLoading = false,
|
||||
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
|
||||
)
|
||||
log.d { "finish $panelName request=$requestKey reason=no-addons ${stateFlow.value.streamDiagnostics()}" }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -216,10 +209,6 @@ object PlayerStreamsRepository {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -233,10 +222,6 @@ object PlayerStreamsRepository {
|
|||
.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(
|
||||
addonName = addon.addonName,
|
||||
|
|
@ -258,7 +243,6 @@ 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 }
|
||||
|
|
@ -287,7 +271,6 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
|
||||
fun publishStreamGroup(group: AddonStreamGroup) {
|
||||
var nextState: StreamsUiState? = null
|
||||
stateFlow.update { current ->
|
||||
val updated = StreamAutoPlaySelector.orderAddonStreams(
|
||||
groups = current.groups.map { currentGroup ->
|
||||
|
|
@ -300,14 +283,7 @@ object PlayerStreamsRepository {
|
|||
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()}"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,16 +329,14 @@ object PlayerStreamsRepository {
|
|||
|
||||
val displayName = addon.addonName
|
||||
runCatching {
|
||||
log.d { "fetch $panelName request=$requestKey addon=$displayName" }
|
||||
val payload = httpGetText(url)
|
||||
StreamParser.parse(payload, displayName, addon.addonId)
|
||||
}.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)
|
||||
},
|
||||
)
|
||||
|
|
@ -371,7 +345,6 @@ object PlayerStreamsRepository {
|
|||
|
||||
val pluginJobs = pluginScrapers.map { scraper ->
|
||||
async {
|
||||
log.d { "fetch $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
PluginRepository.executeScraper(
|
||||
scraper = scraper,
|
||||
tmdbId = pluginContentId(
|
||||
|
|
@ -384,7 +357,6 @@ object PlayerStreamsRepository {
|
|||
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}",
|
||||
|
|
@ -393,7 +365,7 @@ object PlayerStreamsRepository {
|
|||
)
|
||||
},
|
||||
onFailure = { err ->
|
||||
log.w(err) { "failed $panelName request=$requestKey plugin=${scraper.name}" }
|
||||
log.w(err) { "Plugin scraper failed: ${scraper.name}" }
|
||||
AddonStreamGroup(
|
||||
addonName = scraper.name,
|
||||
addonId = "plugin:${scraper.id}",
|
||||
|
|
@ -420,7 +392,6 @@ object PlayerStreamsRepository {
|
|||
for (availabilityJob in debridAvailabilityJobs) {
|
||||
availabilityJob.join()
|
||||
}
|
||||
log.d { "complete $panelName request=$requestKey ${stateFlow.value.streamDiagnostics()}" }
|
||||
launch {
|
||||
DirectDebridStreamPreparer.prepare(
|
||||
streams = stateFlow.value.groups
|
||||
|
|
@ -455,25 +426,6 @@ private data class PlayerInstalledStreamAddonTarget(
|
|||
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
@ -440,8 +432,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 +454,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 +514,6 @@ private fun MobileSettingsScreen(
|
|||
onDownloadsClick = onDownloadsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
onSwitchProfileClick = onSwitchProfile,
|
||||
showDownloadsEntry = AppFeaturePolicy.downloadsEnabled,
|
||||
showNotificationsEntry = AppFeaturePolicy.notificationsEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -580,12 +564,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 +635,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,
|
||||
|
|
@ -820,8 +795,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 +802,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 +893,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,
|
||||
|
|
@ -984,12 +947,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",
|
||||
|
|
@ -800,26 +794,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
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ internal expect object ThemeSettingsStorage {
|
|||
fun saveAmoledEnabled(enabled: Boolean)
|
||||
fun loadLiquidGlassNativeTabBarEnabled(): Boolean?
|
||||
fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean)
|
||||
fun loadDesktopNavigationLayout(): String?
|
||||
fun saveDesktopNavigationLayout(layoutName: String)
|
||||
fun loadSelectedAppLanguage(): String?
|
||||
fun saveSelectedAppLanguage(languageCode: String)
|
||||
fun applySelectedAppLanguage(languageCode: String)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.i18n.localizedByteUnit
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
|
|
|
|||
|
|
@ -77,19 +77,17 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetActionRow
|
||||
import com.nuvio.app.core.ui.NuvioBottomSheetDivider
|
||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||
import com.nuvio.app.core.ui.NuvioToastController
|
||||
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
|
||||
import com.nuvio.app.core.ui.secondaryClick
|
||||
import com.nuvio.app.features.downloads.DownloadsRepository
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.debrid.DebridProviders
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
|
|
@ -146,9 +144,7 @@ fun StreamsScreen(
|
|||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
remember {
|
||||
if (AppFeaturePolicy.downloadsEnabled) {
|
||||
DownloadsRepository.ensureLoaded()
|
||||
}
|
||||
DownloadsRepository.ensureLoaded()
|
||||
}
|
||||
val isEpisode = seasonNumber != null && episodeNumber != null
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
|
@ -347,7 +343,6 @@ fun StreamsScreen(
|
|||
StreamActionsSheet(
|
||||
stream = streamActionsTarget,
|
||||
externalPlayerEnabled = playerSettings.externalPlayerEnabled,
|
||||
showDownloadAction = AppFeaturePolicy.downloadsEnabled,
|
||||
onDismiss = { streamActionsTarget = null },
|
||||
onCopyLink = { stream ->
|
||||
val directUrl = stream.playableDirectUrl
|
||||
|
|
@ -1032,7 +1027,6 @@ private fun StreamCard(
|
|||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
)
|
||||
.secondaryClick(if (enabled) onLongClick else null)
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
@ -1182,7 +1176,6 @@ private fun StreamNameWithInstantService(
|
|||
private fun StreamActionsSheet(
|
||||
stream: StreamItem?,
|
||||
externalPlayerEnabled: Boolean,
|
||||
showDownloadAction: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCopyLink: (StreamItem) -> Unit,
|
||||
onDownload: (StreamItem) -> Unit,
|
||||
|
|
@ -1261,19 +1254,17 @@ private fun StreamActionsSheet(
|
|||
}
|
||||
},
|
||||
)
|
||||
if (showDownloadAction) {
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Rounded.Download,
|
||||
title = stringResource(Res.string.streams_download_file),
|
||||
onClick = {
|
||||
onDownload(stream)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = Icons.Rounded.Download,
|
||||
title = stringResource(Res.string.streams_download_file),
|
||||
onClick = {
|
||||
onDownload(stream)
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,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.sp
|
||||
import com.nuvio.app.core.ui.NuvioAsyncImage as AsyncImage
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.isIos
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppVersionPolicy
|
||||
import com.nuvio.app.core.build.AppVersionConfig
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
|
@ -255,7 +255,7 @@ internal object TraktScrobbleRepository {
|
|||
ids = item.ids.toRequestBodyOrNull(),
|
||||
),
|
||||
progress = clampedProgress,
|
||||
appVersion = AppVersionPolicy.displayVersionName,
|
||||
appVersion = AppVersionConfig.VERSION_NAME,
|
||||
)
|
||||
|
||||
is TraktScrobbleItem.Episode -> TraktScrobbleRequest(
|
||||
|
|
@ -270,7 +270,7 @@ internal object TraktScrobbleRepository {
|
|||
number = item.number,
|
||||
),
|
||||
progress = clampedProgress,
|
||||
appVersion = AppVersionPolicy.displayVersionName,
|
||||
appVersion = AppVersionConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs
|
|||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import co.touchlab.kermit.Logger
|
||||
|
||||
@Composable
|
||||
fun computeAirDateBadgeText(
|
||||
|
|
@ -66,32 +67,46 @@ class ReleaseAlertState(
|
|||
val isNewSeasonRelease: Boolean,
|
||||
)
|
||||
|
||||
private const val ReleaseAlertWindowMs = 60L * 24 * 60 * 60 * 1000
|
||||
private val NoReleaseAlertState = ReleaseAlertState(false, false)
|
||||
|
||||
fun calculateReleaseAlertState(
|
||||
seedLastUpdatedEpochMs: Long,
|
||||
seedSeasonNumber: Int?,
|
||||
nextSeasonNumber: Int?,
|
||||
releasedIso: String?,
|
||||
): ReleaseAlertState {
|
||||
if (releasedIso.isNullOrBlank()) return NoReleaseAlertState
|
||||
|
||||
val releaseEpoch = parseReleaseDateToEpochMs(releasedIso)
|
||||
?: return NoReleaseAlertState
|
||||
|
||||
val nowMs = WatchProgressClock.nowEpochMs()
|
||||
if (nowMs < releaseEpoch) return NoReleaseAlertState
|
||||
if (releaseEpoch <= seedLastUpdatedEpochMs) return NoReleaseAlertState
|
||||
if (nowMs - releaseEpoch >= ReleaseAlertWindowMs) return NoReleaseAlertState
|
||||
|
||||
val isNewSeasonRelease =
|
||||
val log = Logger.withTag("ReleaseAlert")
|
||||
log.d {
|
||||
"calculateReleaseAlertState inputs: releasedIso=$releasedIso, " +
|
||||
"releaseEpoch=$releaseEpoch, seedLastUpdatedEpochMs=$seedLastUpdatedEpochMs, " +
|
||||
"seedSeasonNumber=$seedSeasonNumber, nextSeasonNumber=$nextSeasonNumber, nowMs=$nowMs"
|
||||
}
|
||||
|
||||
if (releaseEpoch == null) {
|
||||
log.d { "calculateReleaseAlertState failed: releaseEpoch is null" }
|
||||
return ReleaseAlertState(false, false)
|
||||
}
|
||||
|
||||
val hasAired = nowMs >= releaseEpoch
|
||||
val sixtyDaysMs = 60L * 24 * 60 * 60 * 1000
|
||||
val isReleaseAlert = hasAired &&
|
||||
releaseEpoch > seedLastUpdatedEpochMs &&
|
||||
(nowMs - releaseEpoch) < sixtyDaysMs
|
||||
|
||||
val isNewSeasonRelease = isReleaseAlert &&
|
||||
seedSeasonNumber != null &&
|
||||
nextSeasonNumber != null &&
|
||||
nextSeasonNumber != seedSeasonNumber
|
||||
|
||||
log.d {
|
||||
"calculateReleaseAlertState result: isReleaseAlert=$isReleaseAlert (hasAired=$hasAired, " +
|
||||
"epoch>seed=${releaseEpoch > seedLastUpdatedEpochMs}, ageMs=${nowMs - releaseEpoch}), " +
|
||||
"isNewSeasonRelease=$isNewSeasonRelease"
|
||||
}
|
||||
|
||||
return ReleaseAlertState(
|
||||
isReleaseAlert = true,
|
||||
isReleaseAlert = isReleaseAlert,
|
||||
isNewSeasonRelease = isNewSeasonRelease
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.player.PlatformPlayerSurface
|
||||
import com.nuvio.app.features.player.desktop.applyNativeDesktopWindowChrome
|
||||
import com.nuvio.app.features.player.desktop.installDesktopAppFullscreenShortcuts
|
||||
import com.nuvio.app.features.player.desktop.preloadNativePlayerBridgeAsync
|
||||
import com.nuvio.app.features.player.desktop.registerDesktopAppFullscreenToggle
|
||||
import java.awt.Color as AwtColor
|
||||
import javax.swing.JComponent
|
||||
|
||||
private val NuvioDesktopNativeBackground = AwtColor(0x0D, 0x0D, 0x0D)
|
||||
private const val NuvioDesktopIconPath = "icons/nuvio-app-icon.png"
|
||||
private const val MacosDarkAquaAppearance = "NSAppearanceNameDarkAqua"
|
||||
|
||||
fun main() {
|
||||
configureDesktopChrome()
|
||||
preloadNativePlayerBridgeAsync()
|
||||
|
||||
application {
|
||||
val smokePlayerUrl = (
|
||||
System.getProperty("nuvio.desktop.smokePlayerUrl")
|
||||
?: System.getenv("NUVIO_DESKTOP_SMOKE_PLAYER_URL")
|
||||
)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val windowState = rememberWindowState(width = 1280.dp, height = 820.dp)
|
||||
val restoreWindowPlacement = remember { mutableStateOf(WindowPlacement.Floating) }
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = if (smokePlayerUrl == null) "Nuvio" else "Nuvio Player Smoke",
|
||||
state = windowState,
|
||||
icon = painterResource(NuvioDesktopIconPath),
|
||||
) {
|
||||
SideEffect {
|
||||
window.background = NuvioDesktopNativeBackground
|
||||
window.rootPane.background = NuvioDesktopNativeBackground
|
||||
window.contentPane.background = NuvioDesktopNativeBackground
|
||||
(window.contentPane as? JComponent)?.isOpaque = true
|
||||
}
|
||||
LaunchedEffect(window) {
|
||||
applyNativeDesktopWindowChrome(window)
|
||||
}
|
||||
DisposableEffect(window, windowState) {
|
||||
val unregisterFullscreenToggle = registerDesktopAppFullscreenToggle { targetWindow ->
|
||||
if (targetWindow != null && targetWindow !== window) return@registerDesktopAppFullscreenToggle
|
||||
if (windowState.placement == WindowPlacement.Fullscreen) {
|
||||
windowState.placement = restoreWindowPlacement.value
|
||||
} else {
|
||||
restoreWindowPlacement.value = windowState.placement
|
||||
.takeUnless { it == WindowPlacement.Fullscreen }
|
||||
?: WindowPlacement.Floating
|
||||
windowState.placement = WindowPlacement.Fullscreen
|
||||
}
|
||||
}
|
||||
val uninstallFullscreenShortcuts = installDesktopAppFullscreenShortcuts(window)
|
||||
onDispose {
|
||||
uninstallFullscreenShortcuts()
|
||||
unregisterFullscreenToggle()
|
||||
}
|
||||
}
|
||||
|
||||
if (smokePlayerUrl == null) {
|
||||
App()
|
||||
} else {
|
||||
PlatformPlayerSurface(
|
||||
sourceUrl = smokePlayerUrl,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onControllerReady = {},
|
||||
onSnapshot = {},
|
||||
onError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureDesktopChrome() {
|
||||
if (System.getProperty("os.name").contains("mac", ignoreCase = true)) {
|
||||
System.setProperty("apple.awt.application.appearance", MacosDarkAquaAppearance)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package com.nuvio.app
|
||||
|
||||
class DesktopPlatform : Platform {
|
||||
override val name: String = "Desktop ${System.getProperty("os.name").orEmpty()}".trim()
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = DesktopPlatform()
|
||||
|
||||
internal actual val isIos: Boolean = false
|
||||
internal actual val isDesktop: Boolean = true
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.nuvio.app.core.auth
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
|
||||
internal actual object AuthStorage {
|
||||
private val store = DesktopStorage.store("nuvio_auth")
|
||||
|
||||
actual fun loadAnonymousUserId(): String? =
|
||||
store.getString("anonymous_user_id")
|
||||
|
||||
actual fun saveAnonymousUserId(userId: String) {
|
||||
store.putString("anonymous_user_id", userId)
|
||||
}
|
||||
|
||||
actual fun clearAnonymousUserId() {
|
||||
store.remove("anonymous_user_id")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
package com.nuvio.app.core.build
|
||||
|
||||
actual object AppFeaturePolicy {
|
||||
actual val pluginsEnabled: Boolean = true
|
||||
actual val downloadsEnabled: Boolean = false
|
||||
actual val notificationsEnabled: Boolean = false
|
||||
actual val pluginsEnabled: Boolean = false
|
||||
actual val p2pEnabled: Boolean = false
|
||||
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.DESKTOP_VERSION_NAME
|
||||
actual val displayVersionCode: Int = AppVersionConfig.DESKTOP_VERSION_CODE
|
||||
actual val basedOnVersionName: String? = AppVersionConfig.VERSION_NAME.takeIf { it != displayVersionName }
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
package com.nuvio.app.core.storage
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.Comparator
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
import kotlin.io.path.exists
|
||||
|
||||
internal object DesktopStorage {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val stores = mutableMapOf<String, Store>()
|
||||
|
||||
val rootDir: Path by lazy {
|
||||
resolveAppDataDir().also { Files.createDirectories(it) }
|
||||
}
|
||||
|
||||
fun store(name: String): Store = synchronized(stores) {
|
||||
stores.getOrPut(name) { Store(rootDir.resolve("$name.properties")) }
|
||||
}
|
||||
|
||||
fun wipe() {
|
||||
synchronized(stores) {
|
||||
stores.values.forEach(Store::clearInMemory)
|
||||
stores.clear()
|
||||
}
|
||||
if (!rootDir.exists()) return
|
||||
Files.walk(rootDir).use { stream ->
|
||||
stream
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.filter { it != rootDir }
|
||||
.forEach { path -> runCatching { Files.deleteIfExists(path) } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAppDataDir(): Path {
|
||||
val osName = System.getProperty("os.name").orEmpty().lowercase(Locale.ROOT)
|
||||
val userHome = Paths.get(System.getProperty("user.home").orEmpty())
|
||||
return when {
|
||||
osName.contains("mac") -> userHome.resolve("Library/Application Support/Nuvio")
|
||||
osName.contains("win") -> {
|
||||
val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
(appData?.let(Paths::get) ?: userHome.resolve("AppData/Roaming")).resolve("Nuvio")
|
||||
}
|
||||
else -> {
|
||||
val xdgConfig = System.getenv("XDG_CONFIG_HOME")?.takeIf { it.isNotBlank() }
|
||||
(xdgConfig?.let(Paths::get) ?: userHome.resolve(".config")).resolve("nuvio")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class Store(
|
||||
private val file: Path,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val properties = Properties()
|
||||
private var loaded = false
|
||||
|
||||
fun contains(key: String): Boolean = synchronized(lock) {
|
||||
ensureLoaded()
|
||||
properties.containsKey(key)
|
||||
}
|
||||
|
||||
fun getString(key: String): String? = synchronized(lock) {
|
||||
ensureLoaded()
|
||||
properties.getProperty(key)
|
||||
}
|
||||
|
||||
fun putString(key: String, value: String?) = synchronized(lock) {
|
||||
ensureLoaded()
|
||||
if (value == null) {
|
||||
properties.remove(key)
|
||||
} else {
|
||||
properties.setProperty(key, value)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
fun getBoolean(key: String): Boolean? =
|
||||
getString(key)?.toBooleanStrictOrNull()
|
||||
|
||||
fun putBoolean(key: String, value: Boolean) {
|
||||
putString(key, value.toString())
|
||||
}
|
||||
|
||||
fun getInt(key: String): Int? =
|
||||
getString(key)?.toIntOrNull()
|
||||
|
||||
fun putInt(key: String, value: Int) {
|
||||
putString(key, value.toString())
|
||||
}
|
||||
|
||||
fun getFloat(key: String): Float? =
|
||||
getString(key)?.toFloatOrNull()
|
||||
|
||||
fun putFloat(key: String, value: Float) {
|
||||
putString(key, value.toString())
|
||||
}
|
||||
|
||||
fun getStringSet(key: String): Set<String>? =
|
||||
getString(key)?.let { payload ->
|
||||
runCatching { json.decodeFromString<List<String>>(payload).toSet() }.getOrNull()
|
||||
}
|
||||
|
||||
fun putStringSet(key: String, values: Set<String>) {
|
||||
putString(key, json.encodeToString(values.toList()))
|
||||
}
|
||||
|
||||
fun remove(key: String) = synchronized(lock) {
|
||||
ensureLoaded()
|
||||
properties.remove(key)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun removeAll(keys: Iterable<String>) = synchronized(lock) {
|
||||
ensureLoaded()
|
||||
keys.forEach(properties::remove)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun clearInMemory() = synchronized(lock) {
|
||||
properties.clear()
|
||||
loaded = false
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
properties.clear()
|
||||
if (!file.exists()) return
|
||||
runCatching {
|
||||
Files.newInputStream(file).use { input ->
|
||||
properties.load(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
Files.createDirectories(file.parent)
|
||||
Files.newOutputStream(file).use { output ->
|
||||
properties.store(output, "Nuvio desktop preferences")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package com.nuvio.app.core.storage
|
||||
|
||||
internal actual object PlatformLocalAccountDataCleaner {
|
||||
actual fun wipe() {
|
||||
DesktopStorage.wipe()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
internal actual object AppForegroundMonitor {
|
||||
actual fun events(): Flow<Unit> = emptyFlow()
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.ic_player_aspect_ratio
|
||||
import nuvio.composeapp.generated.resources.ic_player_audio_filled
|
||||
import nuvio.composeapp.generated.resources.ic_player_pause
|
||||
import nuvio.composeapp.generated.resources.ic_player_play
|
||||
import nuvio.composeapp.generated.resources.ic_player_subtitles
|
||||
import nuvio.composeapp.generated.resources.library_add_plus
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
actual fun appIconPainter(icon: AppIconResource): Painter =
|
||||
painterResource(
|
||||
when (icon) {
|
||||
AppIconResource.PlayerPlay -> Res.drawable.ic_player_play
|
||||
AppIconResource.PlayerPause -> Res.drawable.ic_player_pause
|
||||
AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio
|
||||
AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles
|
||||
AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled
|
||||
AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus
|
||||
},
|
||||
)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false
|
||||
|
||||
internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit
|
||||
|
||||
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
|
||||
|
||||
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
|
||||
|
||||
internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
|
||||
|
||||
internal actual fun publishNativeProfileTabIcon(
|
||||
name: String?,
|
||||
avatarColorHex: String?,
|
||||
avatarImageUrl: String?,
|
||||
avatarBackgroundColorHex: String?,
|
||||
) = Unit
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asComposeImageBitmap
|
||||
import androidx.compose.ui.graphics.asSkiaBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import coil3.BitmapImage
|
||||
import coil3.Image
|
||||
import coil3.PlatformContext
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.NullRequestDataException
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.FilterMipmap
|
||||
import org.jetbrains.skia.FilterMode
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import org.jetbrains.skia.MipmapMode
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MinCustomDownscaleRatio = 1.08f
|
||||
private const val MaxDesktopSourceSizePx = 1536
|
||||
private const val MaxScaledBitmapPixels = 1_250_000L
|
||||
|
||||
private val IsWindowsDesktop: Boolean =
|
||||
System.getProperty("os.name")
|
||||
?.startsWith("Windows", ignoreCase = true)
|
||||
?: false
|
||||
|
||||
@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,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val effectiveDesktopImageScaling = remember(desktopImageScaling) {
|
||||
if (IsWindowsDesktop) desktopImageScaling else NuvioDesktopImageScaling.Disabled
|
||||
}
|
||||
val requestModel = remember(context, model, effectiveDesktopImageScaling) {
|
||||
if (effectiveDesktopImageScaling == NuvioDesktopImageScaling.Disabled) {
|
||||
model
|
||||
} else {
|
||||
model.withDesktopHighQualitySize(context)
|
||||
}
|
||||
}
|
||||
val transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = remember(
|
||||
placeholder,
|
||||
error,
|
||||
fallback,
|
||||
effectiveDesktopImageScaling,
|
||||
) {
|
||||
{ state ->
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading -> {
|
||||
placeholder?.let { state.copy(painter = it) } ?: state
|
||||
}
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
state.result.image.toScaledBitmapPainter(effectiveDesktopImageScaling)
|
||||
?.let { state.copy(painter = it) }
|
||||
?: state
|
||||
}
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
val fallbackPainter = if (state.result.throwable is NullRequestDataException) {
|
||||
fallback ?: error
|
||||
} else {
|
||||
error
|
||||
}
|
||||
fallbackPainter?.let { state.copy(painter = it) } ?: state
|
||||
}
|
||||
AsyncImagePainter.State.Empty -> state
|
||||
}
|
||||
}
|
||||
}
|
||||
val onState: ((AsyncImagePainter.State) -> Unit)? = remember(onLoading, onSuccess, onError) {
|
||||
if (onLoading == null && onSuccess == null && onError == null) {
|
||||
null
|
||||
} else {
|
||||
{ state ->
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Loading -> onLoading?.invoke(state)
|
||||
is AsyncImagePainter.State.Success -> onSuccess?.invoke(state)
|
||||
is AsyncImagePainter.State.Error -> onError?.invoke(state)
|
||||
AsyncImagePainter.State.Empty -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = requestModel,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality ?: FilterQuality.High,
|
||||
clipToBounds = clipToBounds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Any?.withDesktopHighQualitySize(context: PlatformContext): Any? {
|
||||
if (this == null) return null
|
||||
|
||||
return if (this is ImageRequest) {
|
||||
newBuilder()
|
||||
.size(MaxDesktopSourceSizePx)
|
||||
.build()
|
||||
} else {
|
||||
ImageRequest.Builder(context)
|
||||
.data(this)
|
||||
.size(MaxDesktopSourceSizePx)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Image.toScaledBitmapPainter(desktopImageScaling: NuvioDesktopImageScaling): Painter? {
|
||||
if (desktopImageScaling == NuvioDesktopImageScaling.Disabled) return null
|
||||
|
||||
return (this as? BitmapImage)
|
||||
?.bitmap
|
||||
?.asComposeImageBitmap()
|
||||
?.let { imageBitmap -> ScaledBitmapPainter(imageBitmap) }
|
||||
}
|
||||
|
||||
private class ScaledBitmapPainter(
|
||||
private val image: ImageBitmap,
|
||||
) : Painter() {
|
||||
private var cachedSize: IntSize? = null
|
||||
private var cachedBitmap: ImageBitmap? = null
|
||||
private var alpha: Float = DefaultAlpha
|
||||
private var colorFilter: ColorFilter? = null
|
||||
|
||||
override val intrinsicSize: Size =
|
||||
Size(image.width.toFloat(), image.height.toFloat())
|
||||
|
||||
override fun DrawScope.onDraw() {
|
||||
val drawSize = IntSize(
|
||||
width = size.width.roundToInt().coerceAtLeast(1),
|
||||
height = size.height.roundToInt().coerceAtLeast(1),
|
||||
)
|
||||
if (!shouldUseScaledBitmap(drawSize)) {
|
||||
drawSource(drawSize)
|
||||
return
|
||||
}
|
||||
|
||||
val cacheSize = drawSize.cacheSize()
|
||||
if (cacheSize.pixelCount() > MaxScaledBitmapPixels) {
|
||||
drawSource(drawSize)
|
||||
return
|
||||
}
|
||||
|
||||
val bitmap = scaledBitmap(cacheSize)
|
||||
|
||||
drawImage(
|
||||
image = bitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = cacheSize,
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = drawSize,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = if (cacheSize == drawSize) FilterQuality.None else FilterQuality.Medium,
|
||||
)
|
||||
}
|
||||
|
||||
override fun applyAlpha(alpha: Float): Boolean {
|
||||
this.alpha = alpha
|
||||
return true
|
||||
}
|
||||
|
||||
override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {
|
||||
this.colorFilter = colorFilter
|
||||
return true
|
||||
}
|
||||
|
||||
private fun scaledBitmap(size: IntSize): ImageBitmap {
|
||||
cachedBitmap?.let { bitmap ->
|
||||
if (cachedSize == size) return bitmap
|
||||
}
|
||||
return image.scale(size.width, size.height).also { bitmap ->
|
||||
cachedSize = size
|
||||
cachedBitmap = bitmap
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSource(drawSize: IntSize) {
|
||||
drawImage(
|
||||
image = image,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(image.width, image.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = drawSize,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = FilterQuality.High,
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldUseScaledBitmap(drawSize: IntSize): Boolean {
|
||||
if (drawSize.pixelCount() > MaxScaledBitmapPixels) return false
|
||||
|
||||
val widthScale = image.width.toFloat() / drawSize.width.toFloat()
|
||||
val heightScale = image.height.toFloat() / drawSize.height.toFloat()
|
||||
return max(widthScale, heightScale) >= MinCustomDownscaleRatio
|
||||
}
|
||||
|
||||
private fun IntSize.cacheSize(): IntSize {
|
||||
val quantum = cacheQuantum()
|
||||
return IntSize(
|
||||
width = width.roundUpTo(quantum),
|
||||
height = height.roundUpTo(quantum),
|
||||
)
|
||||
}
|
||||
|
||||
private fun IntSize.cacheQuantum(): Int {
|
||||
val longestSide = max(width, height)
|
||||
return when {
|
||||
longestSide >= 1200 -> 16
|
||||
longestSide >= 600 -> 8
|
||||
longestSide >= 200 -> 4
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.roundUpTo(quantum: Int): Int =
|
||||
((this + quantum - 1) / quantum) * quantum
|
||||
|
||||
private fun IntSize.pixelCount(): Long =
|
||||
width.toLong() * height.toLong()
|
||||
|
||||
private fun ImageBitmap.scale(width: Int, height: Int): ImageBitmap {
|
||||
val image = SkiaImage.makeFromBitmap(asSkiaBitmap())
|
||||
return try {
|
||||
image.scale(width, height)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun SkiaImage.scale(width: Int, height: Int): ImageBitmap {
|
||||
val bitmap = Bitmap()
|
||||
bitmap.allocN32Pixels(width, height)
|
||||
scalePixels(
|
||||
bitmap.peekPixels()!!,
|
||||
FilterMipmap(FilterMode.LINEAR, MipmapMode.LINEAR),
|
||||
false,
|
||||
)
|
||||
return bitmap.asComposeImageBitmap()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
|
||||
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
|
||||
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp
|
||||
|
||||
@Composable
|
||||
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0.dp)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun PlatformBackHandler(
|
||||
enabled: Boolean,
|
||||
onBack: () -> Unit,
|
||||
) = Unit
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
actual fun platformExitApp() {
|
||||
exitProcess(0)
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import coil3.ImageLoader
|
||||
|
||||
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object PosterCardStyleStorage {
|
||||
private val store = DesktopStorage.store("nuvio_poster_card_style")
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
store.getString(ProfileScopedKey.of("poster_card_style"))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
store.putString(ProfileScopedKey.of("poster_card_style"), payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerButton
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
internal actual fun Modifier.secondaryClick(onClick: (() -> Unit)?): Modifier {
|
||||
if (onClick == null) return this
|
||||
|
||||
return pointerInput(onClick) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) {
|
||||
event.changes.forEach { change -> change.consume() }
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
internal actual object AddonStorage {
|
||||
private val store = DesktopStorage.store("nuvio_addons")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
|
||||
store.getString("installed_addon_urls_$profileId")
|
||||
?.let { payload -> runCatching { json.decodeFromString<List<String>>(payload) }.getOrNull() }
|
||||
?: emptyList()
|
||||
|
||||
actual fun saveInstalledAddonUrls(profileId: Int, urls: List<String>) {
|
||||
store.putString("installed_addon_urls_$profileId", json.encodeToString(urls))
|
||||
}
|
||||
|
||||
actual fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean> =
|
||||
store.getString("addon_enabled_states_$profileId")
|
||||
?.let { payload -> runCatching { json.decodeFromString<Map<String, Boolean>>(payload) }.getOrNull() }
|
||||
?: emptyMap()
|
||||
|
||||
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
|
||||
store.putString("addon_enabled_states_$profileId", json.encodeToString(states))
|
||||
}
|
||||
}
|
||||
|
||||
private val desktopHttpClient: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(30))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build()
|
||||
|
||||
actual suspend fun httpGetText(url: String): String =
|
||||
httpGetTextWithHeaders(url, emptyMap())
|
||||
|
||||
actual suspend fun httpPostJson(url: String, body: String): String =
|
||||
httpPostJsonWithHeaders(url, body, emptyMap())
|
||||
|
||||
actual suspend fun httpGetTextWithHeaders(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
): String =
|
||||
httpRequestRaw("GET", url, headers, body = "").body
|
||||
|
||||
actual suspend fun httpPostJsonWithHeaders(
|
||||
url: String,
|
||||
body: String,
|
||||
headers: Map<String, String>,
|
||||
): String =
|
||||
httpRequestRaw(
|
||||
method = "POST",
|
||||
url = url,
|
||||
headers = mapOf("Content-Type" to "application/json") + headers,
|
||||
body = body,
|
||||
).body
|
||||
|
||||
actual suspend fun httpRequestRaw(
|
||||
method: String,
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean,
|
||||
): RawHttpResponse = withContext(Dispatchers.IO) {
|
||||
val client = if (followRedirects) {
|
||||
desktopHttpClient
|
||||
} else {
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(30))
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.build()
|
||||
}
|
||||
val normalizedMethod = method.trim().uppercase().ifBlank { "GET" }
|
||||
val requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI(url))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.method(
|
||||
normalizedMethod,
|
||||
if (normalizedMethod == "GET" || normalizedMethod == "HEAD") {
|
||||
HttpRequest.BodyPublishers.noBody()
|
||||
} else {
|
||||
HttpRequest.BodyPublishers.ofString(body)
|
||||
},
|
||||
)
|
||||
|
||||
headers.forEach { (key, value) ->
|
||||
if (key.isNotBlank() && value.isNotBlank()) {
|
||||
requestBuilder.header(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
|
||||
RawHttpResponse(
|
||||
status = response.statusCode(),
|
||||
statusText = "HTTP ${response.statusCode()}",
|
||||
url = response.uri().toString(),
|
||||
body = response.body(),
|
||||
headers = response.headers().map().mapValues { (_, values) -> values.joinToString(",") },
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object CollectionMobileSettingsStorage {
|
||||
private val store = DesktopStorage.store("nuvio_collection_mobile_settings")
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
store.getString(ProfileScopedKey.of("collection_mobile_settings"))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
store.putString(ProfileScopedKey.of("collection_mobile_settings"), payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.features.collection
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object CollectionStorage {
|
||||
private val store = DesktopStorage.store("nuvio_collections")
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
store.getString(ProfileScopedKey.of("collections"))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
store.putString(ProfileScopedKey.of("collections"), payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
package com.nuvio.app.features.debrid
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import com.nuvio.app.core.sync.decodeSyncBoolean
|
||||
import com.nuvio.app.core.sync.decodeSyncInt
|
||||
import com.nuvio.app.core.sync.decodeSyncString
|
||||
import com.nuvio.app.core.sync.encodeSyncBoolean
|
||||
import com.nuvio.app.core.sync.encodeSyncInt
|
||||
import com.nuvio.app.core.sync.encodeSyncString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
internal actual object DebridSettingsStorage {
|
||||
private const val enabledKey = "debrid_enabled"
|
||||
private const val cloudLibraryEnabledKey = "debrid_cloud_library_enabled"
|
||||
private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id"
|
||||
private const val torboxApiKeyKey = "debrid_torbox_api_key"
|
||||
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
|
||||
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
|
||||
private const val streamMaxResultsKey = "debrid_stream_max_results"
|
||||
private const val streamSortModeKey = "debrid_stream_sort_mode"
|
||||
private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
|
||||
private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
|
||||
private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
|
||||
private const val streamCodecFilterKey = "debrid_stream_codec_filter"
|
||||
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||
private val store = DesktopStorage.store("nuvio_debrid_settings")
|
||||
|
||||
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
|
||||
actual fun saveEnabled(enabled: Boolean) = saveBoolean(enabledKey, enabled)
|
||||
actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey)
|
||||
actual fun saveCloudLibraryEnabled(enabled: Boolean) = saveBoolean(cloudLibraryEnabledKey, enabled)
|
||||
actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey)
|
||||
actual fun savePreferredResolverProviderId(providerId: String) = saveString(preferredResolverProviderIdKey, providerId)
|
||||
actual fun loadProviderApiKey(providerId: String): String? = loadString(providerApiKeyKey(providerId))
|
||||
actual fun saveProviderApiKey(providerId: String, apiKey: String) = saveString(providerApiKeyKey(providerId), apiKey)
|
||||
actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID)
|
||||
actual fun saveTorboxApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey)
|
||||
actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID)
|
||||
actual fun saveRealDebridApiKey(apiKey: String) = saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, apiKey)
|
||||
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
|
||||
actual fun saveInstantPlaybackPreparationLimit(limit: Int) = saveInt(instantPlaybackPreparationLimitKey, limit)
|
||||
actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
|
||||
actual fun saveStreamMaxResults(maxResults: Int) = saveInt(streamMaxResultsKey, maxResults)
|
||||
actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
|
||||
actual fun saveStreamSortMode(mode: String) = saveString(streamSortModeKey, mode)
|
||||
actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
|
||||
actual fun saveStreamMinimumQuality(quality: String) = saveString(streamMinimumQualityKey, quality)
|
||||
actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
|
||||
actual fun saveStreamDolbyVisionFilter(filter: String) = saveString(streamDolbyVisionFilterKey, filter)
|
||||
actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
|
||||
actual fun saveStreamHdrFilter(filter: String) = saveString(streamHdrFilterKey, filter)
|
||||
actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
|
||||
actual fun saveStreamCodecFilter(filter: String) = saveString(streamCodecFilterKey, filter)
|
||||
actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
|
||||
actual fun saveStreamPreferences(preferences: String) = saveString(streamPreferencesKey, preferences)
|
||||
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
|
||||
actual fun saveStreamNameTemplate(template: String) = saveString(streamNameTemplateKey, template)
|
||||
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
|
||||
actual fun saveStreamDescriptionTemplate(template: String) = saveString(streamDescriptionTemplateKey, template)
|
||||
|
||||
private fun loadBoolean(key: String): Boolean? = store.getBoolean(ProfileScopedKey.of(key))
|
||||
private fun saveBoolean(key: String, value: Boolean) = store.putBoolean(ProfileScopedKey.of(key), value)
|
||||
private fun loadInt(key: String): Int? = store.getInt(ProfileScopedKey.of(key))
|
||||
private fun saveInt(key: String, value: Int) = store.putInt(ProfileScopedKey.of(key), value)
|
||||
private fun loadString(key: String): String? = store.getString(ProfileScopedKey.of(key))
|
||||
private fun saveString(key: String, value: String) = store.putString(ProfileScopedKey.of(key), value)
|
||||
|
||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
|
||||
loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) }
|
||||
DebridProviders.all().forEach { provider ->
|
||||
loadProviderApiKey(provider.id)?.let { put(providerApiKeyKey(provider.id), encodeSyncString(it)) }
|
||||
}
|
||||
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
|
||||
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
|
||||
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
|
||||
loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
|
||||
loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
|
||||
loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
|
||||
loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
|
||||
loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
|
||||
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
|
||||
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||
store.removeAll(syncKeys().map(ProfileScopedKey::of))
|
||||
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
|
||||
payload.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled)
|
||||
payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId)
|
||||
DebridProviders.all().forEach { provider ->
|
||||
payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { saveProviderApiKey(provider.id, it) }
|
||||
}
|
||||
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
|
||||
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
|
||||
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
|
||||
payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
|
||||
payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
|
||||
payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
|
||||
payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
|
||||
payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
|
||||
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
|
||||
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
|
||||
}
|
||||
|
||||
private fun syncKeys(): List<String> =
|
||||
listOf(
|
||||
enabledKey,
|
||||
cloudLibraryEnabledKey,
|
||||
preferredResolverProviderIdKey,
|
||||
instantPlaybackPreparationLimitKey,
|
||||
streamMaxResultsKey,
|
||||
streamSortModeKey,
|
||||
streamMinimumQualityKey,
|
||||
streamDolbyVisionFilterKey,
|
||||
streamHdrFilterKey,
|
||||
streamCodecFilterKey,
|
||||
streamPreferencesKey,
|
||||
streamNameTemplateKey,
|
||||
streamDescriptionTemplateKey,
|
||||
) + DebridProviders.all().map { providerApiKeyKey(it.id) }
|
||||
|
||||
private fun providerApiKeyKey(providerId: String): String {
|
||||
val normalized = DebridProviders.byId(providerId)?.id
|
||||
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
|
||||
return when (normalized) {
|
||||
DebridProviders.TORBOX_ID -> torboxApiKeyKey
|
||||
DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey
|
||||
else -> "debrid_${normalized}_api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object MetaScreenSettingsStorage {
|
||||
private val store = DesktopStorage.store("nuvio_meta_screen_settings")
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
store.getString(ProfileScopedKey.of("meta_screen_settings"))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
store.putString(ProfileScopedKey.of("meta_screen_settings"), payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object SeasonViewModeStorage {
|
||||
private val store = DesktopStorage.store("nuvio_season_view_mode")
|
||||
|
||||
actual fun load(): SeasonViewMode? =
|
||||
SeasonViewMode.parse(store.getString(ProfileScopedKey.of("season_view_mode")))
|
||||
|
||||
actual fun save(mode: SeasonViewMode) {
|
||||
store.putString(ProfileScopedKey.of("season_view_mode"), SeasonViewMode.persist(mode))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
actual fun HeroTrailerPlayerSurface(
|
||||
sourceUrl: String,
|
||||
sourceAudioUrl: String?,
|
||||
playWhenReady: Boolean,
|
||||
muted: Boolean,
|
||||
modifier: Modifier,
|
||||
onReady: () -> Unit,
|
||||
onEnded: () -> Unit,
|
||||
onError: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(sourceUrl) {
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal actual object DownloadsClock {
|
||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
internal actual object DownloadsLiveStatusPlatform {
|
||||
actual fun onItemsChanged(items: List<DownloadItem>) = Unit
|
||||
}
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
import kotlin.io.path.createDirectories
|
||||
|
||||
private val desktopDownloadHttpClient: HttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(60))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build()
|
||||
|
||||
internal actual object DownloadsPlatformDownloader {
|
||||
private val downloadsDir: File
|
||||
get() = File(DesktopStorage.rootDir.resolve("downloads").also { it.createDirectories() }.toUri())
|
||||
|
||||
actual fun start(
|
||||
request: DownloadPlatformRequest,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit,
|
||||
onFailure: (message: String) -> Unit,
|
||||
): DownloadsTaskHandle {
|
||||
val job = SupervisorJob()
|
||||
val scope = CoroutineScope(job + Dispatchers.IO)
|
||||
|
||||
scope.launch {
|
||||
val destination = File(downloadsDir, request.destinationFileName)
|
||||
val tempFile = File(downloadsDir, "${request.destinationFileName}.part")
|
||||
|
||||
try {
|
||||
var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L
|
||||
var attemptedRangeRequest = resumeFromBytes > 0L
|
||||
var response = sendDownloadRequest(request, if (attemptedRangeRequest) resumeFromBytes else null)
|
||||
|
||||
if (attemptedRangeRequest && response.statusCode() == 416) {
|
||||
tempFile.delete()
|
||||
resumeFromBytes = 0L
|
||||
attemptedRangeRequest = false
|
||||
response = sendDownloadRequest(request, null)
|
||||
}
|
||||
|
||||
if (response.statusCode() !in 200..299) {
|
||||
error("Download failed with HTTP ${response.statusCode()}")
|
||||
}
|
||||
|
||||
val isPartialResume = attemptedRangeRequest && response.statusCode() == 206 && resumeFromBytes > 0L
|
||||
val appendToTemp = isPartialResume
|
||||
val startingBytes = if (appendToTemp) resumeFromBytes else 0L
|
||||
if (!appendToTemp && tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
|
||||
val totalBytes = resolveTotalBytes(
|
||||
startingBytes = startingBytes,
|
||||
isPartialResume = isPartialResume,
|
||||
contentRangeHeader = response.headers().firstValue("Content-Range").orElse(null),
|
||||
contentLength = response.headers().firstValue("Content-Length").orElse(null)?.toLongOrNull(),
|
||||
)
|
||||
var downloadedBytes = startingBytes
|
||||
onProgress(downloadedBytes, totalBytes)
|
||||
|
||||
response.body().use { input ->
|
||||
FileOutputStream(tempFile, appendToTemp).use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read.toLong()
|
||||
onProgress(downloadedBytes, totalBytes)
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.exists()) {
|
||||
destination.delete()
|
||||
}
|
||||
if (!tempFile.renameTo(destination)) {
|
||||
tempFile.copyTo(destination, overwrite = true)
|
||||
tempFile.delete()
|
||||
}
|
||||
|
||||
val finalSize = destination.length()
|
||||
onSuccess(destination.toURI().toString(), totalBytes ?: finalSize)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
onFailure(error.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
|
||||
return DesktopDownloadsTaskHandle(job)
|
||||
}
|
||||
|
||||
actual fun removeFile(localFileUri: String?): Boolean {
|
||||
if (localFileUri.isNullOrBlank()) return false
|
||||
val file = localFileUri.toLocalFileOrNull() ?: return false
|
||||
return runCatching { file.delete() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun removePartialFile(destinationFileName: String): Boolean {
|
||||
val tempFile = File(downloadsDir, "$destinationFileName.part")
|
||||
if (!tempFile.exists()) return true
|
||||
return runCatching { tempFile.delete() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? {
|
||||
localFileUri
|
||||
?.toLocalFileOrNull()
|
||||
?.takeIf { it.exists() }
|
||||
?.let { return it.toURI().toString() }
|
||||
|
||||
val fileName = destinationFileName.trim().takeIf { it.isNotBlank() }
|
||||
?: localFileUri?.toLocalFileOrNull()?.name?.takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
return File(downloadsDir, fileName).takeIf { it.exists() }?.toURI()?.toString()
|
||||
}
|
||||
|
||||
private fun sendDownloadRequest(
|
||||
request: DownloadPlatformRequest,
|
||||
rangeStart: Long?,
|
||||
): HttpResponse<java.io.InputStream> {
|
||||
val builder = HttpRequest.newBuilder()
|
||||
.uri(URI(request.sourceUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.GET()
|
||||
request.sourceHeaders.forEach { (key, value) ->
|
||||
if (key.isNotBlank() && value.isNotBlank()) {
|
||||
builder.header(key, value)
|
||||
}
|
||||
}
|
||||
if (rangeStart != null && rangeStart > 0L) {
|
||||
builder.header("Range", "bytes=$rangeStart-")
|
||||
}
|
||||
return desktopDownloadHttpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream())
|
||||
}
|
||||
}
|
||||
|
||||
private class DesktopDownloadsTaskHandle(
|
||||
private val job: Job,
|
||||
) : DownloadsTaskHandle {
|
||||
override fun cancel() {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toLocalFileOrNull(): File? =
|
||||
runCatching {
|
||||
if (startsWith("file:")) {
|
||||
File(URI(this))
|
||||
} else {
|
||||
File(this)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun resolveTotalBytes(
|
||||
startingBytes: Long,
|
||||
isPartialResume: Boolean,
|
||||
contentRangeHeader: String?,
|
||||
contentLength: Long?,
|
||||
): Long? {
|
||||
parseContentRangeTotal(contentRangeHeader)?.let { return it }
|
||||
val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null
|
||||
return if (isPartialResume && startingBytes > 0L) {
|
||||
startingBytes + normalizedLength
|
||||
} else {
|
||||
normalizedLength
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContentRangeTotal(headerValue: String?): Long? {
|
||||
val value = headerValue?.trim().orEmpty()
|
||||
if (value.isBlank()) return null
|
||||
val slashIndex = value.lastIndexOf('/')
|
||||
if (slashIndex == -1 || slashIndex == value.lastIndex) return null
|
||||
val totalPart = value.substring(slashIndex + 1).trim()
|
||||
if (totalPart == "*") return null
|
||||
return totalPart.toLongOrNull()?.takeIf { it > 0L }
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import com.nuvio.app.core.storage.DesktopStorage
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object DownloadsStorage {
|
||||
private val store = DesktopStorage.store("nuvio_downloads")
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
store.getString(ProfileScopedKey.of("downloads"))
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
store.putString(ProfileScopedKey.of("downloads"), payload)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue