Merge branch 'cmp-rewrite' into pr/liquid-glass-profile-switch

This commit is contained in:
WhiteGiso 2026-06-15 10:09:40 +02:00 committed by GitHub
commit 53d600768f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
168 changed files with 11351 additions and 6066 deletions

View file

@ -9,10 +9,11 @@ body:
value: |
Thanks for reporting a bug.
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
If we can reproduce it, we can usually fix it. Please describe the bug in enough detail that someone else can understand what failed without watching a video or guessing from the title.
Please replace the default title with a short summary of the actual problem.
If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed if the missing details are not added.
If the app crashes or force closes, logs are required. Crash reports without logs will be closed.
- type: markdown
attributes:
@ -121,11 +122,26 @@ body:
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug description
description: |
Explain the problem in detail. Do not only write "not working" or "same issue".
Include what screen/flow you were using, what you tapped or tried to do, what changed on screen, and any visible error text.
placeholder: |
I was trying to ...
The app ...
I saw ...
This happens on ...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
description: Exact steps, one action per line. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
placeholder: |
1. Open ...
2. Navigate to ...
@ -146,7 +162,7 @@ body:
id: actual
attributes:
label: Actual behavior
placeholder: "What actually happened (include any on-screen error text/codes)."
placeholder: "What actually happened? Include any on-screen error text/codes and whether the app froze, crashed, ignored input, showed the wrong content, etc."
validations:
required: true
@ -197,7 +213,7 @@ body:
attributes:
label: Logs (required for crash reports)
description: |
Required if the app crashes or force closes.
Required if the app crashes or force closes. Crash reports without logs will be closed.
For other bug reports, logs are optional but still helpful.
**Android:** `adb logcat -d | tail -n 300`

View file

@ -32,8 +32,12 @@ jobs:
return labels.includes(name.toLowerCase());
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractSection(title) {
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m");
const match = body.match(re);
if (!match) return "";
const start = match.index + match[0].length;
@ -55,10 +59,37 @@ jobs:
return (value || "").replace(/\s+/g, " ").trim();
}
function normalizedMeaningfulText(value) {
return normalizeText(value)
.replace(/^```[a-zA-Z0-9_-]*/i, "")
.replace(/```$/i, "")
.trim();
}
function stripIssuePrefix(value) {
return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
}
function isEmptyish(value) {
const text = normalizedMeaningfulText(value);
return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text);
}
function isVague(value) {
const text = normalizedMeaningfulText(value);
return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text);
}
function hasUsefulText(value, minimumLength) {
const text = normalizedMeaningfulText(value);
return !isEmptyish(text) && !isVague(text) && text.length >= minimumLength;
}
const description = extractFirstSection([
"Bug description",
"Describe the bug",
"Detailed description",
]);
const steps = extractSection("Steps to reproduce");
const expected = extractSection("Expected behavior");
const actual = extractSection("Actual behavior");
@ -69,7 +100,7 @@ jobs:
const extra = extractSection("Anything else? (optional)");
const summaryTitle = stripIssuePrefix(title);
const looksLikeBugForm = !!(steps || expected || actual);
const looksLikeBugForm = !!(description || steps || expected || actual);
const isBugIssue = hasLabel("bug") || looksLikeBugForm;
const isFeatureIssue =
hasLabel("enhancement") ||
@ -84,20 +115,20 @@ jobs:
const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
const numericOnlyTitle = /^#?\d+$/;
const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
const crashContext = [summaryTitle, description, steps, actual, extra].map(normalizeText).join("\n");
const isCrashIssue = crashPattern.test(crashContext);
const normalizedLogs = normalizeText(logs);
const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
const normalizedLogs = normalizedMeaningfulText(logs);
const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs);
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
}
if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
if (!expected || expected.length < 10) problems.push("Expected behavior");
if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
if (isCrashIssue && !hasLogs) {
problems.push("Logs (required for crash reports; include a log snippet or stack trace)");
if (!hasUsefulText(description, 50)) {
problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")");
}
if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)");
if (!hasUsefulText(expected, 10)) problems.push("Expected behavior");
if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)");
async function ensureLabel(name, color, description) {
try {
@ -111,6 +142,50 @@ jobs:
const hasNeedsInfo = hasLabel(NEEDS_INFO);
if (isCrashIssue && !hasLogs) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [NEEDS_INFO],
});
}
const marker = "<!-- nuvio-bot:crash-logs-required-close -->";
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
if (!alreadyCommented) {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body:
`${marker}\n` +
`Closing this crash report because crash reports must include logs.\n\n` +
`Please open a new report, or ask us to reopen this one, with a log snippet or stack trace from around the crash.\n\n` +
`Useful examples:\n` +
`- Android: \`adb logcat -d | tail -n 300\`\n` +
`- iOS: crash log from Xcode Organizer or Console.app\n` +
`- Desktop: terminal/console output from around the crash`,
});
}
await github.rest.issues.update({
owner,
repo,
issue_number,
state: "closed",
state_reason: "not_planned",
});
return;
}
if (problems.length > 0) {
await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
if (!hasNeedsInfo) {
@ -125,12 +200,13 @@ jobs:
const marker = "<!-- nuvio-bot:needs-info -->";
const commentBody =
`${marker}\n` +
`Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
`Missing / too short:\n` +
`Thanks for the report. Could you add more detail so we can reproduce it?\n\n` +
`Missing / too vague:\n` +
problems.map(p => `- ${p}`).join("\n") +
`\n\n` +
`Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` +
`${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`;
`Reports that only say "not working", "broken", or "same issue" are not enough to debug.\n` +
`Logs are optional for most issues, but they help a lot. Crash reports without logs are closed automatically.\n`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,

3
.gitmodules vendored
View file

@ -1,3 +1,4 @@
[submodule "MPVKit"]
path = MPVKit
url = https://github.com/tapframe/MPVNuvio.git
url = https://github.com/NuvioMedia/MPVKit.git
branch = Nuvio

2
MPVKit

@ -1 +1 @@
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
Subproject commit 20afe97c34e46fce08a3794e2afa64613ea62794

View file

@ -188,6 +188,17 @@ val iosDistributionSourceDir = if (iosDistribution == "full") {
val iosFrameworkBundleId = "com.nuvio.media"
val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin")
val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin")
val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName ->
taskName.substringAfterLast(':').lowercase()
}
val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName ->
taskName == "bundle" ||
taskName == "bundlerelease" ||
taskName == "bundledebug" ||
taskName.startsWith("bundleplaystore") ||
taskName.startsWith("bundlefull") ||
taskName.endsWith("bundle")
}
val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
outputDir.set(generatedRuntimeConfigDir)
@ -238,7 +249,6 @@ kotlin {
baseName = "ComposeApp"
isStatic = true
freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId")
export("com.mohamedrejeb.calf:calf-ui:${libs.versions.calf.get()}")
}
}
@ -275,7 +285,6 @@ kotlin {
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
api(libs.calf.ui)
implementation("dev.chrisbanes.haze:haze:1.7.2")
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
@ -307,6 +316,10 @@ afterEvaluate {
}
}
configurations.matching { it.name == "iosMainImplementation" }.configureEach {
project.dependencies.add(name, libs.ktor.client.darwin)
}
dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling)

View file

@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.SubtitleFileCache
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.p2p.P2pSettingsStorage
import com.nuvio.app.features.p2p.P2pStreamingEngine
@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() {
P2pSettingsStorage.initialize(applicationContext)
P2pStreamingEngine.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
SubtitleFileCache.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
ProfilePinCacheStorage.initialize(applicationContext)

View file

@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_profile_pin_cache",
"nuvio_theme_settings",
"nuvio_poster_card_style",
"nuvio_debrid_settings",
"nuvio_mdblist_settings",
"nuvio_downloads",
"nuvio_trakt_auth",
"nuvio_trakt_library",
"nuvio_trakt_settings",

View file

@ -30,6 +30,7 @@ actual object DebridSettingsStorage {
private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_"
private fun syncKeys(): List<String> =
listOf(
enabledKey,
@ -150,6 +151,20 @@ actual object DebridSettingsStorage {
saveString(streamDescriptionTemplateKey, template)
}
actual fun loadPendingDeviceAuthorization(providerId: String): String? =
loadString(pendingDeviceAuthorizationKey(providerId))
actual fun savePendingDeviceAuthorization(providerId: String, payload: String) {
saveString(pendingDeviceAuthorizationKey(providerId), payload)
}
actual fun clearPendingDeviceAuthorization(providerId: String) {
preferences
?.edit()
?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)))
?.apply()
}
private fun loadBoolean(key: String): Boolean? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
@ -249,4 +264,10 @@ actual object DebridSettingsStorage {
else -> "debrid_${normalized}_api_key"
}
}
private fun pendingDeviceAuthorizationKey(providerId: String): String {
val normalized = DebridProviders.byId(providerId)?.id
?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_")
return "$pendingDeviceAuthorizationPrefix$normalized"
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.p2p
import android.content.Context
import android.util.Log
import com.nuvio.app.core.i18n.localizedP2pUnknownTorrentError
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -93,7 +94,7 @@ actual object P2pStreamingEngine {
throw e
} catch (e: Exception) {
if (isCurrentGeneration(generation)) {
_state.value = P2pStreamingState.Error(e.message ?: "Unknown torrent error")
_state.value = P2pStreamingState.Error(e.message ?: localizedP2pUnknownTorrentError())
}
throw e
}

View file

@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform {
}
// Title extras
val displayTitle = request.streamTitle ?: request.title
val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true)
putExtra(Intent.EXTRA_TITLE, displayTitle)
putExtra("title", displayTitle)
putExtra("forcename", displayTitle) // Vimu Player
@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform {
if (request.resumePositionMs > 0L) {
putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv
putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player
putExtra("forceresume", true) // Vimu: enable resume for network streams
putExtra("from_start", false) // VLC: don't force start from beginning
}
@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform {
val subtitleNames = subtitles.map { it.name }.toTypedArray()
val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray()
// Grant read permission for content:// URIs via ClipData.
// FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras.
// Adding all subtitle URIs to ClipData ensures the receiving player
// gets read access to all of them.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val clipData = android.content.ClipData(
"subtitles",
arrayOf("application/x-subrip", "text/vtt"),
android.content.ClipData.Item(subtitleUris.first())
)
subtitleUris.drop(1).forEach { subtitleUri ->
clipData.addItem(android.content.ClipData.Item(subtitleUri))
}
setClipData(clipData)
// MX Player / mpv-android / Nova
putExtra("subs", subtitleUris)
putExtra("subs.name", subtitleNames)
putExtra("subs.filename", subtitleFilenames)
putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url)))
putExtra("subs.enable", arrayOf(subtitleUris.first()))
// Just Player
putExtra("subtitle_uri", subtitleUris)
putExtra("subtitle_name", subtitleNames)
// VLC (single subtitle — use first one)
putExtra("subtitles_location", Uri.parse(subtitles.first().url))
putExtra("subtitles_location", subtitleUris.first())
// Vimu Player
putExtra("forcedsrt", subtitles.first().url)

View file

@ -0,0 +1,232 @@
package com.nuvio.app.features.player
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
internal fun playbackMediaItemFromUrl(
url: String,
responseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
): MediaItem {
val builder = MediaItem.Builder().setUri(url)
inferPlaybackMimeType(
url = url,
responseHeaders = responseHeaders,
streamType = streamType,
)?.let(builder::setMimeType)
return builder.build()
}
private fun inferPlaybackMimeType(
url: String,
responseHeaders: Map<String, String>,
streamType: String?,
): String? =
inferMimeTypeFromStreamType(streamType)
?: inferMimeTypeFromResponseHeaders(responseHeaders)
?: inferMimeTypeFromPath(url)
private fun inferMimeTypeFromStreamType(streamType: String?): String? {
val normalized = streamType
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotBlank() }
?: return null
return when (normalized) {
"hls", "m3u8" -> MimeTypes.APPLICATION_M3U8
"dash", "mpd" -> MimeTypes.APPLICATION_MPD
"smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS
else -> null
}
}
private fun inferMimeTypeFromResponseHeaders(headers: Map<String, String>): String? {
if (headers.isEmpty()) return null
headers.entries
.firstOrNull { (key, _) -> key.equals("Content-Type", ignoreCase = true) }
?.value
?.let(::normalizeMimeType)
?.let { return it }
val contentDisposition = headers.entries
.firstOrNull { (key, _) -> key.equals("Content-Disposition", ignoreCase = true) }
?.value
?: return null
val filename = contentDisposition
.substringAfter("filename*=", missingDelimiterValue = "")
.substringAfterLast("''", missingDelimiterValue = "")
.ifBlank {
contentDisposition.substringAfter("filename=", missingDelimiterValue = "")
}
.trim()
.trim('"', '\'')
.takeIf { it.isNotBlank() }
return inferMimeTypeFromPath(filename)
}
internal fun normalizeMimeType(contentType: String?): String? {
val normalized = contentType
?.substringBefore(';')
?.trim()
?.lowercase(Locale.US)
?: return null
return when (normalized) {
"application/vnd.apple.mpegurl",
"application/mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"application/m3u8" -> MimeTypes.APPLICATION_M3U8
"application/dash+xml",
"video/vnd.mpeg.dash.mpd" -> MimeTypes.APPLICATION_MPD
"application/vnd.ms-sstr+xml" -> MimeTypes.APPLICATION_SS
"video/mp4",
"application/mp4",
"video/x-m4v" -> MimeTypes.VIDEO_MP4
"video/webm",
"audio/webm" -> MimeTypes.VIDEO_WEBM
"video/x-matroska",
"audio/x-matroska",
"video/mkv",
"audio/mkv" -> MimeTypes.VIDEO_MATROSKA
else -> null
}
}
private fun inferMimeTypeFromPath(path: String?): String? {
val normalized = path
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotBlank() }
?: return null
val pathWithoutFragment = normalized.substringBefore('#')
val pathPart = pathWithoutFragment.substringBefore('?')
val queryPart = pathWithoutFragment.substringAfter('?', missingDelimiterValue = "")
val fileName = pathPart.substringAfterLast('/')
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "")
return when {
extension == "m3u8" -> MimeTypes.APPLICATION_M3U8
extension == "mpd" -> MimeTypes.APPLICATION_MPD
extension == "ism" || extension == "isml" -> MimeTypes.APPLICATION_SS
extension == "mkv" -> MimeTypes.VIDEO_MATROSKA
extension == "webm" -> MimeTypes.VIDEO_WEBM
extension == "mp4" || extension == "m4v" -> MimeTypes.VIDEO_MP4
extension == "ts" || extension == "mts" || extension == "m2ts" -> MimeTypes.VIDEO_MP2T
extension == "mov" -> MIME_VIDEO_QUICK_TIME
extension == "avi" -> MimeTypes.VIDEO_AVI
extension == "mpeg" || extension == "mpg" -> MimeTypes.VIDEO_MPEG
else -> inferMimeTypeFromQuery(queryPart)
?: inferMimeTypeFromDelimitedToken(pathPart)
?: inferMimeTypeFromDelimitedToken(queryPart)
}
}
private fun inferMimeTypeFromQuery(query: String): String? {
if (query.isBlank()) return null
query.split('&').forEach { parameter ->
val key = parameter.substringBefore('=', missingDelimiterValue = "").trim()
val value = parameter.substringAfter('=', missingDelimiterValue = "").trim()
if (key.isBlank() || value.isBlank()) return@forEach
when (key) {
"format",
"mime",
"mime_type",
"contenttype",
"content_type",
"type",
"ext",
"extension",
"output" -> when (value.substringAfterLast('/').substringAfterLast('.')) {
"m3u8" -> return MimeTypes.APPLICATION_M3U8
"mpd" -> return MimeTypes.APPLICATION_MPD
"ism", "isml" -> return MimeTypes.APPLICATION_SS
"mkv" -> return MimeTypes.VIDEO_MATROSKA
"webm" -> return MimeTypes.VIDEO_WEBM
"mp4", "m4v" -> return MimeTypes.VIDEO_MP4
"ts", "mts", "m2ts" -> return MimeTypes.VIDEO_MP2T
"mov" -> return MIME_VIDEO_QUICK_TIME
"avi" -> return MimeTypes.VIDEO_AVI
"mpeg", "mpg" -> return MimeTypes.VIDEO_MPEG
}
}
when (value) {
"application/vnd.apple.mpegurl",
"application/mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"application/m3u8",
"hls" -> return MimeTypes.APPLICATION_M3U8
"application/dash+xml",
"video/vnd.mpeg.dash.mpd",
"dash" -> return MimeTypes.APPLICATION_MPD
"application/vnd.ms-sstr+xml",
"smoothstreaming",
"ss" -> return MimeTypes.APPLICATION_SS
}
}
return null
}
private fun inferMimeTypeFromDelimitedToken(value: String): String? =
when {
DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_HLS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8
DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD
DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS
else -> null
}
internal fun probeMimeType(url: String, headers: Map<String, String>): String? {
if (!url.startsWith("http://") && !url.startsWith("https://")) return null
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 3_000
readTimeout = 3_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "Mozilla/5.0")
setRequestProperty("Accept", "*/*")
headers.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
val responseCode = connection.responseCode
if (responseCode in 200..299) {
val contentType = connection.contentType
normalizeMimeType(contentType)
} else null
} finally {
connection.disconnect()
}
}.getOrNull()?.let { return it }
}
return null
}
private const val MIME_VIDEO_QUICK_TIME = "video/quicktime"
private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&%-])m3u8($|[=/_.?&%-])")
private val DELIMITED_HLS_PATTERN = Regex("(^|[=/_.?&%-])hls($|[=/_.?&%-])")
private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&%-])mpd($|[=/_.?&%-])")
private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&%-])(ism|isml)($|[=/_.?&%-])")

View file

@ -56,6 +56,7 @@ import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.CaptionStyleCompat
import com.nuvio.app.R
import com.nuvio.app.features.streams.normalizeStreamType
import io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
@ -75,6 +76,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
playWhenReady: Boolean,
@ -101,6 +103,9 @@ actual fun PlatformPlayerSurface(
val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) {
sanitizePlaybackResponseHeaders(sourceResponseHeaders)
}
val normalizedStreamType = remember(streamType) {
normalizeStreamType(streamType)
}
val useLibass = playerSettings.useLibass
val libassRenderType = runCatching {
LibassRenderType.valueOf(playerSettings.libassRenderType)
@ -110,6 +115,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl.orEmpty(),
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType.orEmpty(),
useYoutubeChunkedPlayback,
)
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
@ -120,6 +126,17 @@ actual fun PlatformPlayerSurface(
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val initialMediaItem = remember(playerSourceKey) {
playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
)
}
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
var probeAttempted by remember(playerSourceKey) { mutableStateOf(false) }
val extractorsFactory = remember {
DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
@ -143,7 +160,7 @@ actual fun PlatformPlayerSurface(
if (!sourceAudioUrl.isNullOrBlank()) {
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem)
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
val audioSource = mediaSourceFactory.createMediaSource(playbackMediaItemFromUrl(sourceAudioUrl))
val mergedSource = MergingMediaSource(videoSource, audioSource)
if (startPositionMs != null) {
setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L))
@ -162,6 +179,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl,
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
normalizedStreamType,
useYoutubeChunkedPlayback,
effectiveDecoderPriority,
) {
@ -221,14 +239,13 @@ actual fun PlatformPlayerSurface(
.build()
}
player.apply {
setPlaybackMediaItem(
videoMediaItem = MediaItem.fromUri(sourceUrl),
startPositionMs = fallbackStartPositionMs,
)
prepare()
this.playWhenReady = playWhenReady
}
player
}
LaunchedEffect(exoPlayer, resolvedMediaItem) {
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
exoPlayer.prepare()
}
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
@ -253,25 +270,54 @@ actual fun PlatformPlayerSurface(
exoPlayer.pause()
}
fun reportPlayerError(error: PlaybackException) {
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
}
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
syncPlayerViewKeepScreenOn()
if (
playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON &&
effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER &&
error.isDecoderFailure()
) {
Log.w(
TAG,
"Decoder failure (${error.errorCodeName}); retrying with app decoders",
error,
)
fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
latestOnError.value(null)
val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ||
error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED ||
error.cause?.toString()?.contains("UnrecognizedInputFormatException") == true
if (isSourceError && !probeAttempted) {
probeAttempted = true
coroutineScope.launch {
val probedMime = withContext(Dispatchers.IO) {
probeMimeType(sourceUrl, sanitizedSourceHeaders)
}
if (probedMime != null) {
Log.d(TAG, "Playback failed with source error. Probed MIME type: $probedMime. Retrying...")
resolvedMediaItem = MediaItem.Builder()
.setUri(sourceUrl)
.setMimeType(probedMime)
.build()
latestOnError.value(null)
return@launch
}
reportPlayerError(error)
}
return
}
latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) })
reportPlayerError(error)
}
override fun onPlaybackStateChanged(playbackState: Int) {

View file

@ -22,7 +22,6 @@ internal object PlayerPlaybackNetworking {
"User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Safari/537.36",
)
internal const val DEFAULT_USER_AGENT =
@ -60,10 +59,29 @@ internal object PlayerPlaybackNetworking {
}
fun createHttpDataSourceFactory(defaultHeaders: Map<String, String> = emptyMap()): DataSource.Factory {
val mergedHeaders = DEFAULT_STREAM_HEADERS + defaultHeaders
return OkHttpDataSource.Factory(playbackHttpClient).apply {
setDefaultRequestProperties(mergedHeaders)
setUserAgent(DEFAULT_USER_AGENT)
val requestHeaders = sanitizeHeaders(defaultHeaders)
val client = requestHeaders.headerValue("Authorization")?.let { authorization ->
playbackHttpClient.newBuilder()
.addNetworkInterceptor { chain ->
val request = chain.request()
if (request.header("Authorization") == null) {
chain.proceed(
request.newBuilder()
.header("Authorization", authorization)
.build()
)
} else {
chain.proceed(request)
}
}
.build()
} ?: playbackHttpClient
return OkHttpDataSource.Factory(client).apply {
setDefaultRequestProperties(requestHeaders)
if (requestHeaders.headerValue("User-Agent") == null) {
setUserAgent(DEFAULT_USER_AGENT)
}
}
}
@ -82,7 +100,7 @@ internal object PlayerPlaybackNetworking {
readTimeoutMs: Int,
range: String? = null,
): HttpURLConnection {
val mergedHeaders = DEFAULT_STREAM_HEADERS + headers
val mergedHeaders = withDefaultUserAgent(headers)
return (URL(url).openConnection() as HttpURLConnection).apply {
if (this is HttpsURLConnection) {
sslSocketFactory = sslContext.socketFactory
@ -92,7 +110,7 @@ internal object PlayerPlaybackNetworking {
connectTimeout = connectTimeoutMs
readTimeout = readTimeoutMs
requestMethod = method
setRequestProperty("User-Agent", mergedHeaders["User-Agent"] ?: DEFAULT_USER_AGENT)
setRequestProperty("User-Agent", mergedHeaders.headerValue("User-Agent") ?: DEFAULT_USER_AGENT)
mergedHeaders.forEach { (key, value) ->
if (key.equals("Range", ignoreCase = true)) return@forEach
if (key.equals("User-Agent", ignoreCase = true)) return@forEach
@ -101,4 +119,24 @@ internal object PlayerPlaybackNetworking {
range?.let { setRequestProperty("Range", it) }
}
}
private fun sanitizeHeaders(headers: Map<String, String>): Map<String, String> =
headers.mapNotNull { (rawKey, rawValue) ->
val key = rawKey.trim()
val value = rawValue.trim()
if (key.isBlank() || value.isBlank() || key.equals("Range", ignoreCase = true)) {
null
} else {
key to value
}
}.toMap()
private fun withDefaultUserAgent(headers: Map<String, String>): Map<String, String> {
val sanitized = sanitizeHeaders(headers)
if (sanitized.headerValue("User-Agent") != null) return sanitized
return DEFAULT_STREAM_HEADERS + sanitized
}
private fun Map<String, String>.headerValue(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
}

View file

@ -23,6 +23,7 @@ actual object PlayerSettingsStorage {
private const val resizeModeKey = "resize_mode"
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
private const val holdToSpeedValueKey = "hold_to_speed_value"
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
private const val externalPlayerEnabledKey = "external_player_enabled"
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
private const val externalPlayerIdKey = "external_player_id"
@ -70,6 +71,7 @@ actual object PlayerSettingsStorage {
private const val iosTargetPrimariesKey = "ios_target_primaries"
private const val iosTargetTransferKey = "ios_target_transfer"
private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode"
private const val iosAudioOutputModeKey = "ios_audio_output_mode"
private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled"
private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled"
private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled"
@ -84,6 +86,7 @@ actual object PlayerSettingsStorage {
resizeModeKey,
holdToSpeedEnabledKey,
holdToSpeedValueKey,
touchGesturesEnabledKey,
externalPlayerEnabledKey,
externalPlayerForwardSubtitlesKey,
externalPlayerIdKey,
@ -129,6 +132,7 @@ actual object PlayerSettingsStorage {
iosTargetPrimariesKey,
iosTargetTransferKey,
iosHardwareDecoderModeKey,
iosAudioOutputModeKey,
iosExtendedDynamicRangeEnabledKey,
iosTargetColorspaceHintEnabledKey,
iosHdrComputePeakEnabledKey,
@ -207,6 +211,23 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadTouchGesturesEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(touchGesturesEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, true)
} else {
null
}
}
actual fun saveTouchGesturesEnabled(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(touchGesturesEnabledKey), enabled)
?.apply()
}
actual fun loadExternalPlayerEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(externalPlayerEnabledKey)
@ -865,6 +886,13 @@ actual object PlayerSettingsStorage {
preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply()
}
actual fun loadIosAudioOutputMode(): String? =
preferences?.getString(ProfileScopedKey.of(iosAudioOutputModeKey), null)
actual fun saveIosAudioOutputMode(mode: String) {
preferences?.edit()?.putString(ProfileScopedKey.of(iosAudioOutputModeKey), mode)?.apply()
}
private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(keyBase)
@ -949,6 +977,7 @@ actual object PlayerSettingsStorage {
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) }
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
@ -994,6 +1023,7 @@ actual object PlayerSettingsStorage {
loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) }
loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) }
loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) }
loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) }
loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) }
loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) }
loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) }
@ -1014,6 +1044,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled)
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles)
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
@ -1061,6 +1092,7 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries)
payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer)
payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode)
payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode)
payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled)
payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled)
payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled)

View file

@ -0,0 +1,11 @@
package com.nuvio.app.features.player
/**
* Android implementation: downloads subtitles to local cache and returns content:// URIs
* via FileProvider so external players can read them.
*/
actual object SubtitleCacheProvider {
actual suspend fun cacheForExternalPlayer(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
return SubtitleFileCache.cacheSubtitles(subtitles)
}
}

View file

@ -0,0 +1,130 @@
package com.nuvio.app.features.player
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
/**
* Downloads subtitle files from remote URLs to local cache and provides
* content:// URIs via FileProvider so external players can read them.
*/
object SubtitleFileCache {
private const val TAG = "SubtitleFileCache"
private const val SUBTITLE_CACHE_DIR = "subtitles"
private var appContext: Context? = null
private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }
fun initialize(context: Context) {
appContext = context.applicationContext
}
private val cacheDir: File?
get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } }
/**
* Downloads subtitle files and returns updated [SubtitleInput] list with
* content:// URIs instead of HTTP URLs.
*
* Subtitles that fail to download are silently skipped (not included in result).
* Returns null if no subtitles could be downloaded.
*/
suspend fun cacheSubtitles(subtitles: List<SubtitleInput>): List<SubtitleInput>? {
if (subtitles.isEmpty()) return null
val context = appContext ?: return null
// Clean old cached subtitles before downloading new ones
clearCache()
val cached = subtitles.mapNotNull { input ->
try {
val localUri = downloadToCache(context, input)
if (localUri != null) {
input.copy(url = localUri.toString())
} else {
Log.w(TAG, "Failed to download subtitle: ${input.name}")
null
}
} catch (e: Exception) {
Log.w(TAG, "Error caching subtitle: ${input.name}", e)
null
}
}
return cached.ifEmpty { null }
}
/**
* Downloads a single subtitle file and returns a content:// URI.
*/
private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? =
withContext(Dispatchers.IO) {
val dir = cacheDir ?: return@withContext null
val extension = guessExtension(input.url)
val filename = sanitizeFilename("${input.lang}_${input.name}.$extension")
val file = File(dir, filename)
val request = Request.Builder()
.url(input.url)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}")
return@withContext null
}
val body = response.body ?: return@withContext null
file.outputStream().use { output ->
body.byteStream().copyTo(output)
}
}
// Return content:// URI via FileProvider
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
} catch (e: Exception) {
Log.w(TAG, "Failed to download subtitle file: ${input.url}", e)
file.delete()
null
}
}
/**
* Removes all cached subtitle files.
*/
fun clearCache() {
try {
cacheDir?.listFiles()?.forEach { it.delete() }
} catch (e: Exception) {
Log.w(TAG, "Failed to clear subtitle cache", e)
}
}
private fun guessExtension(url: String): String {
val path = url.substringBefore('?').substringBefore('#').trimEnd('/')
return when {
path.endsWith(".vtt", ignoreCase = true) -> "vtt"
path.endsWith(".ass", ignoreCase = true) -> "ass"
path.endsWith(".ssa", ignoreCase = true) -> "ssa"
path.endsWith(".ttml", ignoreCase = true) -> "ttml"
path.endsWith(".dfxp", ignoreCase = true) -> "dfxp"
else -> "srt"
}
}
private fun sanitizeFilename(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_")
.take(100) // Limit filename length
}
}

View file

@ -24,7 +24,6 @@ actual object ThemeSettingsStorage {
amoledEnabledKey,
liquidGlassNativeTabBarEnabledKey,
)
private val globalSyncKeys = listOf(selectedAppLanguageKey)
private var preferences: SharedPreferences? = null
@ -94,19 +93,16 @@ actual object ThemeSettingsStorage {
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply {
profileScopedSyncKeys.forEach { remove(ProfileScopedKey.of(it)) }
globalSyncKeys.forEach { remove(it) }
}?.apply()
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage)
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code)
}
}

View file

@ -16,9 +16,11 @@ actual object StreamBadgeSettingsStorage {
private const val legacyDebridPreferencesName = "nuvio_debrid_settings"
private const val streamBadgeRulesKey = "stream_badge_rules"
private const val showFileSizeBadgesKey = "show_file_size_badges"
private const val showAddonLogoKey = "show_addon_logo"
private const val streamBadgePlacementKey = "stream_badge_placement"
private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules"
private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey)
private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey)
private var preferences: SharedPreferences? = null
private var legacyDebridPreferences: SharedPreferences? = null
@ -40,6 +42,18 @@ actual object StreamBadgeSettingsStorage {
saveBoolean(showFileSizeBadgesKey, enabled)
}
actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey)
actual fun saveShowAddonLogo(enabled: Boolean) {
saveBoolean(showAddonLogoKey, enabled)
}
actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey)
actual fun saveStreamBadgePlacement(placement: String) {
saveString(streamBadgePlacementKey, placement)
}
actual fun loadLegacyDebridStreamBadgeRules(): String? =
legacyDebridPreferences?.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey), null)
@ -80,6 +94,7 @@ actual object StreamBadgeSettingsStorage {
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) }
loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) }
loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
@ -89,5 +104,6 @@ actual object StreamBadgeSettingsStorage {
payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules)
payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges)
payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement)
}
}

View file

@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage {
?.putString(key, payload)
?.apply()
}
actual fun removePayload(key: String) {
preferences
?.edit()
?.remove(key)
?.apply()
}
}

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="nuvio_background">#020404</color>
<color name="nuvio_background">#0D0D0D</color>
</resources>

View file

@ -3,6 +3,9 @@
<cache-path
name="nuvio_updates"
path="updates/" />
<cache-path
name="nuvio_subtitles"
path="subtitles/" />
<files-path
name="nuvio_downloads"
path="downloads/" />

View file

@ -320,8 +320,11 @@
<string name="compose_nav_search">Rechercher</string>
<string name="compose_player_audio_tracks">Pistes audio</string>
<string name="compose_player_audio">Audio</string>
<string name="compose_player_auto_sync">Synchro auto</string>
<string name="compose_player_bold">Gras</string>
<string name="compose_player_built_in">Intégré</string>
<string name="compose_player_bottom_offset">Décalage inférieur</string>
<string name="compose_player_capture_line">Capturer</string>
<string name="compose_player_close">Fermer le lecteur</string>
<string name="compose_player_color">Couleur</string>
<string name="compose_player_currently_playing">En cours de lecture</string>
@ -332,11 +335,15 @@
<string name="compose_player_font_size">Taille de police</string>
<string name="compose_player_font_size_value">%1$dsp</string>
<string name="compose_player_lock_controls">Verrouiller les contrôles du lecteur</string>
<string name="compose_player_loading_lines">Chargement des lignes de sous-titres…</string>
<string name="compose_player_no_subtitle_lines_found">Aucune ligne de sous-titres trouvée</string>
<string name="compose_player_subtitle_lines_load_error">Impossible de charger les lignes de sous-titres</string>
<string name="compose_player_no_audio_tracks_available">Aucune piste audio disponible</string>
<string name="compose_player_no_episodes_available">Aucun épisode disponible</string>
<string name="compose_player_no_streams_found">Aucun stream trouvé</string>
<string name="compose_player_none">Aucun</string>
<string name="compose_player_outline">Contour</string>
<string name="compose_player_outline_color">Couleur du contour</string>
<string name="compose_player_panel_episodes">Épisodes</string>
<string name="compose_player_panel_sources">Sources</string>
<string name="compose_player_panel_streams">Streams</string>
@ -344,6 +351,8 @@
<string name="compose_player_playing">Lecture en cours</string>
<string name="compose_player_fetch_subtitles">Appuie pour chercher des sous-titres</string>
<string name="compose_player_go_back">Retour</string>
<string name="compose_player_reload">Recharger</string>
<string name="compose_player_reset">Réinitialiser</string>
<string name="compose_player_reset_defaults">Rétablir les valeurs par défaut</string>
<string name="compose_player_resize_fill">Remplir</string>
<string name="compose_player_resize_fit">Ajuster</string>
@ -356,8 +365,11 @@
<string name="compose_player_seek_forward_10">Avancer de 10 secondes</string>
<string name="compose_player_sources">Sources</string>
<string name="compose_player_style">Style</string>
<string name="compose_player_select_addon_subtitle_first">Sélectionne dabord un sous-titre daddon</string>
<string name="compose_player_subs">Sous-titres</string>
<string name="compose_player_subtitle_delay">Délai des sous-titres</string>
<string name="compose_player_subtitles">Sous-titres</string>
<string name="compose_player_text_opacity">Opacité du texte</string>
<string name="compose_player_brightness_level">Luminosité %1$s</string>
<string name="compose_player_volume_level">Volume %1$s</string>
<string name="compose_player_muted">Muet</string>
@ -386,6 +398,7 @@
<string name="compose_settings_category_general">Général</string>
<string name="compose_settings_page_account">Compte</string>
<string name="compose_settings_page_addons">Addons</string>
<string name="compose_settings_page_advanced">Avancé</string>
<string name="compose_settings_page_appearance">Apparence</string>
<string name="compose_settings_page_content_discovery">Contenu et découverte</string>
<string name="compose_settings_page_continue_watching">Continuer à regarder</string>
@ -400,12 +413,15 @@
<string name="compose_settings_page_plugins">Plugins</string>
<string name="compose_settings_page_poster_customization">Personnalisation des affiches</string>
<string name="compose_settings_page_root">Paramètres</string>
<string name="compose_settings_page_streams">Streams</string>
<string name="compose_settings_page_supporters_contributors">Supporters et contributeurs</string>
<string name="compose_settings_page_tmdb_enrichment">Enrichissement TMDB</string>
<string name="compose_settings_page_trakt">Trakt</string>
<string name="compose_settings_root_about_section">À PROPOS</string>
<string name="compose_settings_root_account_description">Gère ton compte, déconnecte-toi ou supprime-le.</string>
<string name="compose_settings_root_account_section">COMPTE</string>
<string name="compose_settings_root_advanced_description">Comportement au démarrage et des profils.</string>
<string name="compose_settings_root_advanced_section">AVANCÉ</string>
<string name="compose_settings_root_appearance_description">Ajuste la présentation de laccueil et les préférences visuelles.</string>
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de lapplication.</string>
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
@ -415,12 +431,20 @@
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
<string name="compose_settings_root_integrations_description">Connecte les services TMDB et MDBList.</string>
<string name="compose_settings_root_notifications_description">Gère les alertes de sortie dépisodes et envoie une notification de test.</string>
<string name="compose_settings_root_streams_description">Affichage des résultats de streams et règles dURL de badges.</string>
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
<string name="compose_settings_root_switch_profile_title">Changer de profil</string>
<string name="compose_settings_root_trakt_description">Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt.</string>
<string name="settings_search_empty">Aucun paramètre trouvé.</string>
<string name="settings_search_placeholder">Rechercher dans les paramètres…</string>
<string name="settings_search_results_section">RÉSULTATS</string>
<string name="settings_advanced_section_startup">DÉMARRAGE</string>
<string name="settings_advanced_section_cache">CACHE</string>
<string name="settings_advanced_remember_last_profile">Mémoriser le dernier profil</string>
<string name="settings_advanced_remember_last_profile_description">Mémorise le dernier profil sélectionné au démarrage</string>
<string name="settings_advanced_clear_cw_cache">Vider le cache de Continuer à regarder</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Supprime les miniatures, titres et données denrichissement en cache pour Continuer à regarder</string>
<string name="settings_advanced_clear_cw_cache_done">Cache vidé</string>
<string name="settings_licenses_attributions_section_app">LICENCE DE LAPP</string>
<string name="settings_licenses_attributions_section_data">DONNÉES ET SERVICES</string>
<string name="settings_licenses_attributions_section_playback">LICENCE DE LECTURE</string>
@ -584,6 +608,8 @@
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur lécran daccueil.</string>
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
<string name="settings_continue_watching_style_card">Carte</string>
<string name="settings_continue_watching_style_card_description">Carte paysage façon TV</string>
<string name="settings_continue_watching_style_poster">Affiche</string>
<string name="settings_continue_watching_style_poster_description">Carte daffiche centrée sur la couverture</string>
<string name="settings_continue_watching_style_wide">Large</string>
@ -646,6 +672,38 @@
<string name="settings_debrid_description_template_description">Contrôle les métadonnées affichées sous chaque résultat.</string>
<string name="settings_debrid_formatter_reset_title">Réinitialiser la mise en forme</string>
<string name="settings_debrid_formatter_reset_subtitle">Restaurer la mise en forme par défaut des résultats.</string>
<string name="settings_stream_badges_section">Style Fusion</string>
<string name="settings_stream_size_badges_title">Badges de taille</string>
<string name="settings_stream_size_badges_description">Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur.</string>
<string name="settings_stream_addon_logo_title">Logo de laddon</string>
<string name="settings_stream_addon_logo_description">Affiche le logo et le nom de laddon à côté des sources de streams.</string>
<string name="settings_stream_display_section">Affichage</string>
<string name="settings_stream_badge_position_title">Position des badges</string>
<string name="settings_stream_badge_position_description">Choisis si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux.</string>
<string name="settings_stream_badge_position_dialog_title">Position des badges</string>
<string name="settings_stream_badge_position_dialog_description">Choisis où les badges de flux apparaissent sur les cartes de flux.</string>
<string name="settings_stream_badge_position_top">En haut</string>
<string name="settings_stream_badge_position_bottom">En bas</string>
<string name="settings_stream_badge_urls_title">URL de badges Fusion</string>
<string name="settings_stream_badge_urls_description">Importe jusquà %1$d URL JSON de badges de flux de style Fusion. Chaque URL peut être mise à jour ou supprimée séparément.</string>
<string name="settings_stream_badge_urls_search_description">Gère les URL JSON de badges de flux de style Fusion importées.</string>
<string name="settings_stream_badge_import_failed">Échec de limport du badge.</string>
<string name="settings_stream_badge_enter_url">Saisis une URL JSON de badge.</string>
<string name="settings_stream_badge_url_scheme_invalid">LURL du badge doit commencer par http:// ou https://.</string>
<string name="settings_stream_badge_import_limit">Tu peux importer jusquà %1$d URL de badges.</string>
<string name="settings_fusion_badges_summary">%1$d/%2$d URL, %3$d badges Fusion actifs</string>
<string name="settings_fusion_badges_empty">Aucune URL de badge Fusion importée.</string>
<string name="settings_fusion_badge_url_label">URL JSON du badge Fusion</string>
<string name="settings_fusion_badge_urls_imported">%1$d/%2$d URL Fusion importées</string>
<string name="settings_fusion_badge_url_active">Active</string>
<string name="settings_fusion_badge_url_inactive">Inactive</string>
<string name="settings_fusion_badge_url_status_summary">%1$s · %2$d badges activés · %3$d groupes</string>
<string name="settings_fusion_badge_preview_action">Aperçu</string>
<string name="settings_fusion_badge_preview_title">Aperçu du badge Fusion</string>
<string name="settings_fusion_badge_preview_count">%1$d badges de style Fusion depuis cette URL</string>
<string name="settings_fusion_badge_preview_empty">Aucune image de badge de style Fusion dans cette URL.</string>
<string name="settings_fusion_badge_group_title">Groupe %1$d</string>
<string name="settings_fusion_badge_other_group_title">Autres badges Fusion</string>
<string name="settings_debrid_key_valid">Clé API validée.</string>
<string name="settings_debrid_key_invalid">Impossible de valider cette clé API.</string>
<string name="settings_mdb_add_api_key_first">Ajoute ta clé API MDBList ci-dessous avant dactiver les notes.</string>
@ -669,6 +727,8 @@
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
<string name="settings_meta_details">Détails</string>
<string name="settings_meta_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
<string name="settings_meta_hero_trailer_playback">Lecture des bandes-annonces dans le Hero</string>
<string name="settings_meta_hero_trailer_playback_description">Lit un aperçu de la bande-annonce dans le Hero des métadonnées quand une bande-annonce est disponible.</string>
<string name="settings_meta_episode_cards">Cartes dépisodes</string>
<string name="settings_meta_episode_cards_description">Choisis comment les épisodes sont affichés sur lécran de métadonnées.</string>
<string name="settings_meta_episode_style_horizontal">Horizontal</string>
@ -768,10 +828,18 @@
<string name="settings_playback_external_player_app">App de lecteur externe</string>
<string name="settings_playback_external_player_description_android">Ouvre les nouvelles lectures avec lapp vidéo par défaut dAndroid ou le sélecteur système.</string>
<string name="settings_playback_external_player_description_ios">Ouvre les nouvelles lectures avec le lecteur installé sélectionné.</string>
<string name="settings_playback_external_player_forward_subtitles">Transmettre les sous-titres au lecteur externe</string>
<string name="settings_playback_external_player_forward_subtitles_description">Récupère les sous-titres daddons dans ta langue préférée et les transmet au lecteur externe.</string>
<string name="settings_playback_external_player_none_available">Aucun lecteur externe compatible nest installé</string>
<string name="settings_playback_player_preference">Lecteur</string>
<string name="settings_playback_player_preference_internal">Interne</string>
<string name="settings_playback_player_preference_external">Externe</string>
<string name="settings_playback_player_preference_description">Choisis quel lecteur gère les nouvelles lectures.</string>
<string name="settings_playback_hold_speed">Vitesse au maintien</string>
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
<string name="settings_playback_hold_to_speed_description">Garde le doigt appuyé nimporte où sur le lecteur pour accélérer temporairement.</string>
<string name="settings_playback_touch_gestures">Gestes tactiles</string>
<string name="settings_playback_touch_gestures_description">Autorise les balayages et les doubles appuis sur le lecteur pour avancer ou reculer, ajuster la luminosité ou le volume.</string>
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
@ -785,6 +853,13 @@
<string name="settings_playback_option_device_language">Langue de lappareil</string>
<string name="settings_playback_option_forced">Forcé</string>
<string name="settings_playback_option_none">Aucun</string>
<string name="settings_playback_addon_subtitle_startup_all">Tous les sous-titres</string>
<string name="settings_playback_addon_subtitle_startup_all_description">Récupère et affiche tous les sous-titres daddons pour la vidéo.</string>
<string name="settings_playback_addon_subtitle_startup_fast">Démarrage rapide</string>
<string name="settings_playback_addon_subtitle_startup_fast_description">Ignore la récupération automatique des sous-titres daddons jusquà ce que tu la demandes dans le lecteur.</string>
<string name="settings_playback_addon_subtitle_startup_mode">Sous-titres daddons au démarrage</string>
<string name="settings_playback_addon_subtitle_startup_preferred">Préférés uniquement</string>
<string name="settings_playback_addon_subtitle_startup_preferred_description">Récupère les sous-titres daddons, mais naffiche que ceux dans tes langues préférées.</string>
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
<string name="settings_playback_prefer_binge_group_description">Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel.</string>
<string name="settings_playback_reuse_binge_group">Réutiliser le groupe de binge</string>
@ -829,6 +904,20 @@
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage dun stream.</string>
<string name="settings_playback_subtitle_background_color">Couleur darrière-plan</string>
<string name="settings_playback_subtitle_bold">Gras</string>
<string name="settings_playback_subtitle_bold_description">Utilise une graisse de police plus épaisse pour les sous-titres.</string>
<string name="settings_playback_subtitle_color_transparent">Transparent</string>
<string name="settings_playback_subtitle_outline">Contour</string>
<string name="settings_playback_subtitle_outline_color">Couleur du contour</string>
<string name="settings_playback_subtitle_outline_description">Dessine une bordure autour du texte des sous-titres.</string>
<string name="settings_playback_subtitle_show_preferred_only">Afficher uniquement les langues préférées</string>
<string name="settings_playback_subtitle_show_preferred_only_description">Naffiche que les sous-titres correspondant à tes langues de sous-titres préférées.</string>
<string name="settings_playback_subtitle_size">Taille des sous-titres</string>
<string name="settings_playback_subtitle_text_color">Couleur du texte</string>
<string name="settings_playback_subtitle_use_forced">Utiliser les sous-titres forcés</string>
<string name="settings_playback_subtitle_use_forced_description">Privilégie les sous-titres forcés selon tes préférences de langue de sous-titres.</string>
<string name="settings_playback_subtitle_vertical_offset">Décalage vertical</string>
<string name="settings_playback_skip_intro_outro_recap">Passer lintro/outro/récap</string>
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments dintro, doutro et de récapitulatif détectés.</string>
<string name="settings_playback_source_scope">Périmètre des sources</string>
@ -928,6 +1017,12 @@
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Source de progression réglée sur Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Source de progression réglée sur Nuvio Sync</string>
<string name="trakt_more_like_this_source_title">Source de la section Similaires</string>
<string name="trakt_more_like_this_source_subtitle">Choisis la provenance des recommandations sur les pages de détails</string>
<string name="trakt_more_like_this_source_dialog_title">Source de la section Similaires</string>
<string name="trakt_more_like_this_source_dialog_subtitle">Sélectionne la source des recommandations affichées sur les pages de détails.</string>
<string name="trakt_more_like_this_source_trakt">Trakt</string>
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
<string name="trakt_continue_watching_window">Fenêtre de Continuer à regarder</string>
<string name="trakt_continue_watching_subtitle">Historique Trakt pris en compte pour Continuer à regarder</string>
<string name="trakt_cw_window_title">Fenêtre de Continuer à regarder</string>
@ -1049,10 +1144,13 @@
<string name="app_exit_title">Quitter lapplication</string>
<string name="catalog_empty_message">Ce catalogue na renvoyé aucun élément.</string>
<string name="catalog_empty_title">Aucun titre trouvé</string>
<string name="details_actions_menu_label">Plus dactions</string>
<string name="details_check_connection">Vérifie ta connexion WiFi ou données mobiles et réessaie.</string>
<string name="details_director">Réalisateur</string>
<string name="details_failed_to_load">Échec du chargement</string>
<string name="details_more_like_this">À voir aussi</string>
<string name="detail_more_like_this_powered_by_tmdb">Données fournies par TMDB</string>
<string name="detail_more_like_this_powered_by_trakt">Données fournies par Trakt</string>
<string name="details_seasons">Saisons</string>
<string name="details_series_missing_numbers">Cet addon a renvoyé des vidéos pour la série, mais aucune nincluait de numéros de saison ou dépisode.</string>
<string name="details_series_no_metadata">Cet addon na fourni aucune métadonnée dépisode pour cette série.</string>
@ -1084,6 +1182,8 @@
<string name="episode_mark_watched">Marquer comme vu</string>
<string name="home_continue_watching_up_next">À suivre</string>
<string name="home_continue_watching_watched">%1$s vu</string>
<string name="home_continue_watching_hours_minutes_left">%1$d h %2$d min restantes</string>
<string name="home_continue_watching_minutes_left">%1$d min restantes</string>
<string name="home_empty_no_active_addons_message">Installe et valide au moins un addon avant de charger des lignes de catalogue à laccueil.</string>
<string name="home_empty_no_rows_message">Les addons installés nexposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
<string name="home_empty_no_rows_title">Aucune ligne daccueil disponible</string>
@ -1179,6 +1279,7 @@
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
<string name="streams_fetching">Récupération…</string>
<string name="streams_finding_source">Recherche de la source…</string>
<string name="streams_loading_subtitles">Chargement des sous-titres depuis les addons…</string>
<string name="streams_finding_streams">Recherche des streams…</string>
<string name="streams_link_copied">Lien du stream copié</string>
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
@ -1283,6 +1384,16 @@
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
<string name="trakt_user_fallback">Utilisateur Trakt</string>
<string name="trakt_watchlist">Liste de suivi</string>
<!-- Trakt library sync errors -->
<string name="trakt_error_authorization_expired">Autorisation Trakt expirée</string>
<string name="trakt_error_list_not_found">Liste Trakt introuvable</string>
<string name="trakt_error_list_limit_reached">Limite de listes Trakt atteinte</string>
<string name="trakt_error_rate_limit_reached">Limite de requêtes Trakt atteinte</string>
<string name="trakt_error_request_failed">Échec de la requête Trakt</string>
<string name="trakt_error_add_watchlist_failed">Échec de lajout à ta liste de suivi Trakt</string>
<string name="trakt_error_add_list_failed">Échec de lajout à la liste Trakt</string>
<string name="trakt_error_missing_ids">Identifiants Trakt compatibles manquants</string>
<string name="trakt_error_empty_response">Réponse vide du serveur</string>
<string name="tmdb_sources_api_key_required">Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB.</string>
<string name="tmdb_sources_collection_fallback_title">Collection TMDB %1$d</string>
<string name="tmdb_sources_collection_not_found">Collection TMDB introuvable</string>
@ -1483,6 +1594,11 @@
<string name="settings_playback_ios_display_color_hint_desc">Permettre à mpv de cibler par défaut lespace colorimétrique de lécran actif.</string>
<string name="settings_playback_ios_target_primaries">Primaires cibles</string>
<string name="settings_playback_ios_target_transfer">Transfert cible</string>
<string name="settings_playback_ios_audio_output_section">Sortie audio iOS</string>
<string name="settings_playback_ios_audio_output">Sortie audio</string>
<string name="settings_playback_ios_audio_output_auto_desc">Essaie dabord AVFoundation, puis bascule sur AudioUnit.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Prise en charge expérimentale de laudio spatial et de la sortie multicanal.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Utilise lancienne sortie AudioUnit.</string>
<!-- Debrid Result Management section -->
<string name="settings_debrid_section_result_management">Gestion des résultats</string>
<string name="settings_debrid_max_results">Nombre max de résultats</string>
@ -1498,6 +1614,7 @@
<!-- Debrid misc -->
<string name="settings_debrid_learn_more">En savoir plus</string>
<string name="settings_debrid_template_default_format">Format par défaut</string>
<string name="settings_debrid_template_original_format">Format dorigine</string>
<string name="settings_debrid_release_groups_hint">Saisis un groupe par ligne.</string>
<!-- Debrid sort profiles -->
<string name="settings_debrid_sort_original">Ordre original</string>
@ -1582,6 +1699,7 @@
<string name="submit_intro_capture">Capturer</string>
<!-- iOS advanced playback -->
<string name="settings_playback_ios_hw_decoder_dialog">Décodeur matériel</string>
<string name="settings_playback_ios_audio_output_dialog">Sortie audio</string>
<string name="settings_playback_ios_target_primaries_dialog">Primaires cibles</string>
<string name="settings_playback_ios_target_transfer_dialog">Transfert cible</string>
<!-- Collection editor -->
@ -1760,4 +1878,21 @@
<!-- Pass 4 — platform stubs and dynamic title fallbacks -->
<string name="collections_editor_trakt_list_title_format">Liste Trakt %1$s</string>
<string name="external_player_android_system">Lecteur système Android</string>
<string name="p2p_consent_title">Streaming P2P</string>
<string name="p2p_consent_body">Ce flux utilise la technologie pair-à-pair (P2P). En activant le P2P, tu reconnais et acceptes que :\n\n• Ton adresse IP sera visible par les autres pairs du réseau\n• Tu es seul responsable du contenu auquel tu accèdes\n• Tu confirmes avoir le droit légal de visionner ce contenu dans ta juridiction\n• Nuvio nhéberge, ne distribue ni ne contrôle aucun contenu P2P\n• Nuvio ne peut être tenu responsable des conséquences légales liées à ton utilisation du streaming P2P\n\nTu utilises cette fonctionnalité entièrement à tes propres risques. Le P2P peut être désactivé à tout moment dans les paramètres.</string>
<string name="p2p_consent_enable">Activer le P2P</string>
<string name="p2p_consent_cancel">Annuler</string>
<string name="p2p_error_unknown">Erreur torrent inconnue</string>
<string name="settings_p2p_title">Streaming P2P</string>
<string name="settings_p2p_subtitle">Autoriser les flux pair-à-pair (torrent)</string>
<string name="settings_p2p_hide_stats_title">Masquer les statistiques torrent</string>
<string name="settings_p2p_hide_stats_subtitle">Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture</string>
<string name="player_torrent_peer_info">%1$d sources · %2$d pairs</string>
<string name="player_torrent_buffered_status">%1$s en mémoire tampon · %2$s · %3$s</string>
<string name="player_torrent_status">%1$s · %2$s</string>
<string name="player_torrent_stats">%1$d pairs · %2$d sources · %3$d %%</string>
<string name="player_torrent_connecting_peers">Connexion aux pairs…</string>
<string name="player_torrent_starting_engine">Démarrage du moteur P2P…</string>
<string name="player_error_failed_start_torrent">Impossible de démarrer le torrent : %1$s</string>
<string name="player_error_torrent">Erreur de torrent : %1$s</string>
</resources>

View file

@ -1456,4 +1456,7 @@
<string name="cloud_library_type_torrents">Torrenty</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="settings_stream_addon_logo_title">Logo dodatku</string>
<string name="settings_stream_addon_logo_description">Pokazuj logo i nazwę dodatku obok źródeł.</string>
<string name="settings_stream_display_section">Wyświetlanie</string>
</resources>

View file

@ -344,6 +344,8 @@
<string name="compose_player_font_size_value">%1$dsp</string>
<string name="compose_player_lock_controls">Lock player controls</string>
<string name="compose_player_loading_lines">Loading subtitle lines...</string>
<string name="compose_player_no_subtitle_lines_found">No subtitle lines found</string>
<string name="compose_player_subtitle_lines_load_error">Unable to load subtitle lines</string>
<string name="compose_player_no_audio_tracks_available">No audio tracks available</string>
<string name="compose_player_no_episodes_available">No episodes available</string>
<string name="compose_player_no_streams_found">No streams found</string>
@ -404,6 +406,7 @@
<string name="compose_settings_category_general">General</string>
<string name="compose_settings_page_account">Account</string>
<string name="compose_settings_page_addons">Addons</string>
<string name="compose_settings_page_advanced">Advanced</string>
<string name="compose_settings_page_appearance">Layout</string>
<string name="compose_settings_page_content_discovery">Content &amp; Discovery</string>
<string name="compose_settings_page_continue_watching">Continue Watching</string>
@ -425,6 +428,8 @@
<string name="compose_settings_root_about_section">ABOUT</string>
<string name="compose_settings_root_account_description">Account and sync status</string>
<string name="compose_settings_root_account_section">ACCOUNT</string>
<string name="compose_settings_root_advanced_description">Startup and profile behavior.</string>
<string name="compose_settings_root_advanced_section">ADVANCED</string>
<string name="compose_settings_root_appearance_description">Home structure and poster styles</string>
<string name="compose_settings_root_check_updates_description">Download latest release</string>
<string name="compose_settings_root_check_updates_title">Check for updates</string>
@ -441,6 +446,13 @@
<string name="settings_search_empty">No settings found.</string>
<string name="settings_search_placeholder">Search settings...</string>
<string name="settings_search_results_section">RESULTS</string>
<string name="settings_advanced_section_startup">STARTUP</string>
<string name="settings_advanced_section_cache">CACHE</string>
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string>
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>
<string name="settings_licenses_attributions_section_app">APP LICENSE</string>
<string name="settings_licenses_attributions_section_data">DATA &amp; SERVICES</string>
<string name="settings_licenses_attributions_section_playback">PLAYBACK LICENSE</string>
@ -671,9 +683,22 @@
<string name="settings_stream_badges_section">Fusion Style</string>
<string name="settings_stream_size_badges_title">Size badges</string>
<string name="settings_stream_size_badges_description">Show file size badges in stream results and player source panels.</string>
<string name="settings_stream_addon_logo_title">Addon logo</string>
<string name="settings_stream_addon_logo_description">Show addon logo and name next to stream sources.</string>
<string name="settings_stream_display_section">Display</string>
<string name="settings_stream_badge_position_title">Badge position</string>
<string name="settings_stream_badge_position_description">Choose whether Fusion and size badges appear above or below stream cards.</string>
<string name="settings_stream_badge_position_dialog_title">Badge position</string>
<string name="settings_stream_badge_position_dialog_description">Select where stream badges appear on stream cards.</string>
<string name="settings_stream_badge_position_top">Top</string>
<string name="settings_stream_badge_position_bottom">Bottom</string>
<string name="settings_stream_badge_urls_title">Fusion badge URLs</string>
<string name="settings_stream_badge_urls_description">Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately.</string>
<string name="settings_stream_badge_urls_search_description">Manage imported Fusion-style stream badge JSON URLs.</string>
<string name="settings_stream_badge_import_failed">Badge import failed.</string>
<string name="settings_stream_badge_enter_url">Enter a badge JSON URL.</string>
<string name="settings_stream_badge_url_scheme_invalid">Badge URL must start with http:// or https://.</string>
<string name="settings_stream_badge_import_limit">You can import up to %1$d badge URLs.</string>
<string name="settings_fusion_badges_summary">%1$d/%2$d URLs, %3$d active Fusion badges</string>
<string name="settings_fusion_badges_empty">No Fusion badge URLs imported.</string>
<string name="settings_fusion_badge_url_label">Fusion badge JSON URL</string>
@ -821,6 +846,8 @@
<string name="settings_playback_hold_speed">Hold Speed</string>
<string name="settings_playback_hold_to_speed">Hold To Speed</string>
<string name="settings_playback_hold_to_speed_description">Long-press anywhere on the player surface to temporarily boost playback speed.</string>
<string name="settings_playback_touch_gestures">Touch Gestures</string>
<string name="settings_playback_touch_gestures_description">Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust volume.</string>
<string name="settings_playback_invalid_regex_pattern">Invalid regex pattern</string>
<string name="settings_playback_last_link_cache_duration">Last Link Cache Duration</string>
<string name="settings_playback_map_dv7_to_hevc">DV7 - HEVC Fallback</string>
@ -998,6 +1025,12 @@
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Watch progress source set to Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Watch progress source set to Nuvio Sync</string>
<string name="trakt_more_like_this_source_title">More Like This source</string>
<string name="trakt_more_like_this_source_subtitle">Choose where recommendations come from on detail pages</string>
<string name="trakt_more_like_this_source_dialog_title">More Like This source</string>
<string name="trakt_more_like_this_source_dialog_subtitle">Select the source for recommendations shown on detail pages.</string>
<string name="trakt_more_like_this_source_trakt">Trakt</string>
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
<string name="trakt_continue_watching_window">Continue Watching Window</string>
<string name="trakt_continue_watching_subtitle">Trakt history considered for continue watching</string>
<string name="trakt_cw_window_title">Continue Watching Window</string>
@ -1119,10 +1152,13 @@
<string name="app_exit_title">Exit app</string>
<string name="catalog_empty_message">This catalog did not return any items.</string>
<string name="catalog_empty_title">No titles found</string>
<string name="details_actions_menu_label">More actions</string>
<string name="details_check_connection">Check your Wi-Fi or mobile data connection and try again.</string>
<string name="details_director">Director</string>
<string name="details_failed_to_load">Failed to load</string>
<string name="details_more_like_this">More Like This</string>
<string name="detail_more_like_this_powered_by_tmdb">Powered by TMDB</string>
<string name="detail_more_like_this_powered_by_trakt">Powered by Trakt</string>
<string name="details_seasons">Seasons</string>
<string name="details_series_missing_numbers">This addon returned videos for the series, but none included season or episode numbers.</string>
<string name="details_series_no_metadata">This addon did not provide episode metadata for this series.</string>
@ -1360,6 +1396,16 @@
<string name="trakt_sign_in_complete_failed">Failed to complete Trakt sign in</string>
<string name="trakt_user_fallback">Trakt user</string>
<string name="trakt_watchlist">Watchlist</string>
<!-- Trakt library sync errors -->
<string name="trakt_error_authorization_expired">Trakt authorization expired</string>
<string name="trakt_error_list_not_found">Trakt list not found</string>
<string name="trakt_error_list_limit_reached">Trakt list limit reached</string>
<string name="trakt_error_rate_limit_reached">Trakt rate limit reached</string>
<string name="trakt_error_request_failed">Trakt request failed</string>
<string name="trakt_error_add_watchlist_failed">Failed to add to Trakt watchlist</string>
<string name="trakt_error_add_list_failed">Failed to add to Trakt list</string>
<string name="trakt_error_missing_ids">Missing compatible Trakt IDs</string>
<string name="trakt_error_empty_response">Empty response body</string>
<string name="tmdb_sources_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
<string name="tmdb_sources_collection_fallback_title">TMDB Collection %1$d</string>
<string name="tmdb_sources_collection_not_found">TMDB collection not found</string>
@ -1560,6 +1606,12 @@
<string name="settings_playback_ios_display_color_hint_desc">Let mpv target the active display color space by default.</string>
<string name="settings_playback_ios_target_primaries">Target primaries</string>
<string name="settings_playback_ios_target_transfer">Target transfer</string>
<!-- Playback settings iOS audio output -->
<string name="settings_playback_ios_audio_output_section">iOS audio output</string>
<string name="settings_playback_ios_audio_output">Audio output</string>
<string name="settings_playback_ios_audio_output_auto_desc">Try AVFoundation first, then fall back to AudioUnit.</string>
<string name="settings_playback_ios_audio_output_avfoundation_desc">Experimental support for Spatial Audio and multichannel output.</string>
<string name="settings_playback_ios_audio_output_audiounit_desc">Use the legacy AudioUnit output.</string>
<!-- Debrid Result Management section -->
<string name="settings_debrid_section_result_management">Result Management</string>
<string name="settings_debrid_max_results">Max results</string>
@ -1660,6 +1712,7 @@
<string name="submit_intro_capture">Capture</string>
<!-- iOS advanced playback -->
<string name="settings_playback_ios_hw_decoder_dialog">Hardware decoder</string>
<string name="settings_playback_ios_audio_output_dialog">Audio output</string>
<string name="settings_playback_ios_target_primaries_dialog">Target primaries</string>
<string name="settings_playback_ios_target_transfer_dialog">Target transfer</string>
<!-- Collection editor -->
@ -1826,6 +1879,7 @@
<string name="p2p_consent_body">This stream uses peer-to-peer (P2P) technology. By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You confirm you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears no liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P can be disabled anytime in Settings.</string>
<string name="p2p_consent_enable">Enable P2P</string>
<string name="p2p_consent_cancel">Cancel</string>
<string name="p2p_error_unknown">Unknown torrent error</string>
<string name="settings_p2p_title">P2P Streaming</string>
<string name="settings_p2p_subtitle">Allow peer-to-peer (torrent) streams</string>
<string name="settings_p2p_hide_stats_title">Hide torrent stats</string>

View file

@ -56,7 +56,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@ -68,7 +67,6 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import coil3.ImageLoader
@ -98,19 +96,19 @@ import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.NuvioFloatingPrompt
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.NuvioTheme
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
import com.nuvio.app.core.ui.NativeNavigationTab
import com.nuvio.app.core.ui.NativeTabBridge
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.catalog.CatalogTargetKind
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
@ -305,12 +303,64 @@ data class StreamRoute(
data class CatalogRoute(
val title: String,
val subtitle: String,
val manifestUrl: String,
val type: String,
val catalogId: String,
val targetKind: CatalogTargetKind,
val contentType: String,
val supportsPagination: Boolean = false,
val manifestUrl: String? = null,
val addonCatalogId: String? = null,
val genre: String? = null,
)
val librarySectionType: String? = null,
val collectionId: String? = null,
val folderId: String? = null,
val sourceKey: String? = null,
) {
constructor(
title: String,
subtitle: String,
target: CatalogTarget,
) : this(
title = title,
subtitle = subtitle,
targetKind = when (target) {
is CatalogTarget.Addon -> CatalogTargetKind.ADDON
is CatalogTarget.Library -> CatalogTargetKind.LIBRARY
is CatalogTarget.CollectionSource -> CatalogTargetKind.COLLECTION_SOURCE
},
contentType = target.contentType,
supportsPagination = target.supportsPagination,
manifestUrl = (target as? CatalogTarget.Addon)?.manifestUrl,
addonCatalogId = (target as? CatalogTarget.Addon)?.catalogId,
genre = (target as? CatalogTarget.Addon)?.genre,
librarySectionType = (target as? CatalogTarget.Library)?.sectionType,
collectionId = (target as? CatalogTarget.CollectionSource)?.collectionId,
folderId = (target as? CatalogTarget.CollectionSource)?.folderId,
sourceKey = (target as? CatalogTarget.CollectionSource)?.sourceKey,
)
fun toCatalogTarget(): CatalogTarget =
when (targetKind) {
CatalogTargetKind.ADDON -> CatalogTarget.Addon(
manifestUrl = requireNotNull(manifestUrl),
contentType = contentType,
catalogId = requireNotNull(addonCatalogId),
genre = genre,
supportsPagination = supportsPagination,
)
CatalogTargetKind.LIBRARY -> CatalogTarget.Library(
contentType = contentType,
sectionType = requireNotNull(librarySectionType),
)
CatalogTargetKind.COLLECTION_SOURCE -> CatalogTarget.CollectionSource(
collectionId = requireNotNull(collectionId),
folderId = requireNotNull(folderId),
sourceKey = requireNotNull(sourceKey),
contentType = contentType,
supportsPagination = supportsPagination,
)
}
}
private data class PosterActionTarget(
val preview: MetaPreview,
@ -346,6 +396,9 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba
streamTitle = streamTitle,
sourceHeaders = sourceHeaders,
resumePositionMs = initialPositionMs,
season = seasonNumber,
episode = episodeNumber,
episodeTitle = episodeTitle,
)
private enum class AppGateScreen {
@ -419,6 +472,20 @@ fun App() {
var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
fun rememberedStartupProfile(profiles: List<NuvioProfile>): NuvioProfile? {
val currentProfileState = ProfileRepository.state.value
if (
!currentProfileState.rememberLastProfileEnabled ||
!currentProfileState.hasEverSelectedProfile
) {
return null
}
return profiles
.find { it.profileIndex == ProfileRepository.activeProfileId }
?.takeUnless { it.pinEnabled }
}
fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
if (profiles.isEmpty()) {
autoSkipProfileSelection = true
@ -426,9 +493,23 @@ fun App() {
return
}
rememberedStartupProfile(profiles)?.let { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(profile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
return
}
autoSkipProfileSelection = true
if (profiles.size == 1) {
val onlyProfile = profiles.first()
if (onlyProfile.pinEnabled) {
gateScreen = AppGateScreen.ProfileSelection.name
return
}
ProfileRepository.selectProfile(onlyProfile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
@ -442,21 +523,26 @@ fun App() {
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
val cachedProfiles = profileState.profiles
val allowOfflineProfileAccess =
val hasCachedProfileAccess =
cachedProfiles.isNotEmpty() &&
authState !is AuthState.Authenticated &&
networkStatusUiState.condition != NetworkCondition.Online
authState !is AuthState.Authenticated
val allowCachedProfileAccess =
hasCachedProfileAccess &&
(
networkStatusUiState.condition != NetworkCondition.Online ||
gateScreen != AppGateScreen.Auth.name
)
when (authState) {
is AuthState.Loading -> {
if (allowOfflineProfileAccess) {
if (hasCachedProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
gateScreen = AppGateScreen.Loading.name
}
}
is AuthState.Unauthenticated -> {
if (allowOfflineProfileAccess) {
if (allowCachedProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
ProfileRepository.clearInMemory()
@ -479,13 +565,32 @@ fun App() {
ProfileRepository.pullProfiles()
}
LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) {
LaunchedEffect(
gateScreen,
autoSkipProfileSelection,
profileState.profiles,
profileState.hasEverSelectedProfile,
profileState.rememberLastProfileEnabled,
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.pinEnabled,
) {
if (
autoSkipProfileSelection &&
gateScreen == AppGateScreen.ProfileSelection.name &&
profileState.profiles.size == 1
gateScreen == AppGateScreen.ProfileSelection.name
) {
rememberedStartupProfile(profileState.profiles)?.let { profile ->
ProfileRepository.selectProfile(profile.profileIndex)
SyncManager.pullAllForProfile(profile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
return@LaunchedEffect
}
if (profileState.profiles.size != 1) return@LaunchedEffect
val onlyProfile = profileState.profiles.first()
if (onlyProfile.pinEnabled) return@LaunchedEffect
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
@ -506,10 +611,10 @@ fun App() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
.background(MaterialTheme.nuvio.colors.background),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent)
}
}
AppGateScreen.Auth.name -> {
@ -835,7 +940,6 @@ private fun MainAppContent(
}
var resumePromptItem by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var lastExternalPlayerLaunch by remember { mutableStateOf<PlayerLaunch?>(null) }
val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf<Long>() }
val launchExternalPlayer = rememberExternalPlayerLauncher { result ->
if (result != null && result.positionMs > 0L) {
coroutineScope.launch {
@ -916,6 +1020,14 @@ private fun MainAppContent(
}
}
LaunchedEffect(currentBackStackEntry?.destination) {
val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute<StreamRoute>() == true ||
currentBackStackEntry?.destination?.hasRoute<PlayerRoute>() == true
if (inPlaybackFlow) {
resumePromptItem = null
}
}
LaunchedEffect(navController) {
AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink ->
when (deepLink) {
@ -1177,10 +1289,7 @@ private fun MainAppContent(
CatalogRoute(
title = section.title,
subtitle = section.subtitle,
manifestUrl = section.manifestUrl,
type = section.type,
catalogId = section.catalogId,
supportsPagination = section.supportsPagination,
target = section.target,
),
)
}
@ -1196,15 +1305,16 @@ private fun MainAppContent(
CatalogRoute(
title = section.displayTitle,
subtitle = librarySectionSubtitle,
manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL,
type = section.items.firstOrNull()?.type ?: "movie",
catalogId = section.type,
supportsPagination = false,
target = CatalogTarget.Library(
contentType = section.items.firstOrNull()?.type ?: "movie",
sectionType = section.type,
),
),
)
}
val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning ->
resumePromptItem = null
if (item.isCloudLibraryContinueWatchingItem()) {
coroutineScope.launch {
when (
@ -1288,7 +1398,7 @@ private fun MainAppContent(
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
.background(MaterialTheme.nuvio.colors.background),
) {
SharedTransitionLayout {
NavHost(
@ -1633,8 +1743,7 @@ private fun MainAppContent(
modifier = Modifier.fillMaxSize(),
)
}
val streamRouteDestination: @Composable (NavBackStackEntry) -> Unit =
streamRouteDestination@{ backStackEntry ->
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
val launch = remember(route.launchId) {
StreamLaunchStore.get(route.launchId)
@ -1644,7 +1753,7 @@ private fun MainAppContent(
StreamsRepository.clear()
navController.popBackStack()
}
return@streamRouteDestination
return@composable
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
@ -1654,9 +1763,7 @@ private fun MainAppContent(
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
if (route.launchId !in streamLaunchIdsPreservedForPlayerReturn) {
StreamLaunchStore.remove(route.launchId)
}
StreamLaunchStore.remove(route.launchId)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@ -1725,21 +1832,6 @@ private fun MainAppContent(
fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
fun navigateToPlayer(
playerLaunch: PlayerLaunch,
replaceStreamRoute: Boolean = false,
) {
playerLaunch.returnStreamLaunchId?.let { streamLaunchId ->
streamLaunchIdsPreservedForPlayerReturn.add(streamLaunchId)
}
val playerLaunchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = playerLaunchId)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
fun openP2pStream(
stream: StreamItem,
resolvedResumePositionMs: Long?,
@ -1747,7 +1839,7 @@ private fun MainAppContent(
replaceStreamRoute: Boolean,
) {
val infoHash = stream.p2pInfoHash ?: return
val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx)
val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
type = launch.type,
@ -1767,7 +1859,7 @@ private fun MainAppContent(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.fileIdx,
fileIdx = stream.p2pFileIdx,
sources = stream.sources,
bingeGroup = stream.behaviorHints.bingeGroup,
)
@ -1777,6 +1869,7 @@ private fun MainAppContent(
sourceUrl = sentinelUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1795,19 +1888,20 @@ private fun MainAppContent(
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
torrentInfoHash = infoHash,
torrentFileIdx = stream.fileIdx,
torrentFileIdx = stream.p2pFileIdx,
torrentFilename = stream.behaviorHints.filename,
torrentTrackers = stream.p2pTrackers,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
returnStreamLaunchId = if (isIos && !replaceStreamRoute) route.launchId else null,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = replaceStreamRoute,
)
navController.navigate(PlayerRoute(launchId = launchId)) {
if (replaceStreamRoute) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
fun requestOrOpenP2pStream(
@ -1895,6 +1989,7 @@ private fun MainAppContent(
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
streamType = cached.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -1923,10 +2018,10 @@ private fun MainAppContent(
}
StreamsRepository.clear()
reuseNavigated = true
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
}
@ -2020,6 +2115,7 @@ private fun MainAppContent(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
@ -2027,6 +2123,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2055,10 +2152,10 @@ private fun MainAppContent(
}
StreamsRepository.consumeAutoPlay()
StreamsRepository.cancelLoading()
navigateToPlayer(
playerLaunch = playerLaunch,
replaceStreamRoute = true,
)
val launchId = PlayerLaunchStore.put(playerLaunch)
navController.navigate(PlayerRoute(launchId = launchId)) {
popUpTo<StreamRoute> { inclusive = true }
}
}
if (!hasResolvedVideoId) {
@ -2066,9 +2163,9 @@ private fun MainAppContent(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent)
}
return@streamRouteDestination
return@composable
}
fun openSelectedStream(
@ -2144,6 +2241,7 @@ private fun MainAppContent(
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}
val playerLaunch = PlayerLaunch(
@ -2151,6 +2249,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2170,7 +2269,6 @@ private fun MainAppContent(
parentMetaType = launch.parentMetaType ?: launch.type,
initialPositionMs = resolvedResumePositionMs ?: 0L,
initialProgressFraction = resolvedResumeProgressFraction,
returnStreamLaunchId = if (isIos) route.launchId else null,
)
if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) {
@ -2179,8 +2277,11 @@ private fun MainAppContent(
return
}
val launchId = PlayerLaunchStore.put(playerLaunch)
StreamsRepository.cancelLoading()
navigateToPlayer(playerLaunch)
navController.navigate(
PlayerRoute(launchId = launchId)
)
}
// Hide overlay when reuse navigated to external player (prevents reload from showing it again)
@ -2190,142 +2291,90 @@ private fun MainAppContent(
}
}
val streamSheetState = rememberNuvioBottomSheetState(skipPartiallyExpanded = true)
fun closeStreamRoute() {
streamLaunchIdsPreservedForPlayerReturn.remove(route.launchId)
StreamsRepository.clear()
navController.popBackStack()
}
fun dismissStreamRoute() {
if (isIos) {
streamRouteScope.launch {
dismissNuvioBottomSheet(
sheetState = streamSheetState,
onDismiss = ::closeStreamRoute,
Box(modifier = Modifier.fillMaxSize()) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
)
}
} else {
closeStreamRoute()
}
}
val streamRouteContent: @Composable (Modifier) -> Unit = { contentModifier ->
Box(modifier = contentModifier) {
StreamsScreen(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId ?: effectiveVideoId,
parentMetaType = launch.parentMetaType ?: launch.type,
title = launch.title,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
resumePositionMs = launch.resumePositionMs,
resumeProgressFraction = launch.resumeProgressFraction,
manualSelection = launch.manualSelection,
startFromBeginning = launch.startFromBeginning,
onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = false,
forceInternal = false,
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = {
StreamsRepository.clear()
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
pendingP2pStreamOpen?.let { pending ->
P2pConsentDialog(
onEnableP2p = {
P2pSettingsRepository.setP2pEnabled(true)
pendingP2pStreamOpen = null
openP2pStream(
stream = pending.stream,
resolvedResumePositionMs = pending.resumePositionMs,
resolvedResumeProgressFraction = pending.resumeProgressFraction,
replaceStreamRoute = pending.isAutoPlay,
)
},
onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction ->
openSelectedStream(
stream = stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = openExternally,
forceInternal = !openExternally,
)
},
onBack = { dismissStreamRoute() },
modifier = Modifier.fillMaxSize(),
showBackButton = !isIos,
)
pendingP2pStreamOpen?.let { pending ->
P2pConsentDialog(
onEnableP2p = {
P2pSettingsRepository.setP2pEnabled(true)
pendingP2pStreamOpen = null
openP2pStream(
stream = pending.stream,
resolvedResumePositionMs = pending.resumePositionMs,
resolvedResumeProgressFraction = pending.resumeProgressFraction,
replaceStreamRoute = pending.isAutoPlay,
)
},
onDismiss = {
if (pending.isAutoPlay) {
StreamsRepository.skipAutoPlayStream(pending.stream)
StreamsRepository.consumeAutoPlay()
}
pendingP2pStreamOpen = null
},
)
}
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(color = Color.White)
Text(
text = stringResource(Res.string.streams_finding_source),
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
)
onDismiss = {
if (pending.isAutoPlay) {
StreamsRepository.skipAutoPlayStream(pending.stream)
StreamsRepository.consumeAutoPlay()
}
pendingP2pStreamOpen = null
},
)
}
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.overlayScrim.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.cardPadding),
) {
CircularProgressIndicator(color = MaterialTheme.nuvio.colors.playerControlsForeground)
Text(
text = stringResource(Res.string.streams_finding_source),
color = MaterialTheme.nuvio.colors.playerControlsForeground.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
if (isIos) {
NuvioModalBottomSheet(
onDismissRequest = { closeStreamRoute() },
sheetState = streamSheetState,
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
iosContentTopPadding = 0.dp,
) {
streamRouteContent(
Modifier
.fillMaxWidth()
.weight(1f),
)
}
} else {
streamRouteContent(Modifier.fillMaxSize())
}
}
if (isIos) {
dialog<StreamRoute>(
dialogProperties = DialogProperties(
usePlatformDefaultWidth = false,
),
) { backStackEntry ->
streamRouteDestination(backStackEntry)
}
} else {
composable<StreamRoute> { backStackEntry ->
streamRouteDestination(backStackEntry)
}
}
composable<PlayerRoute>(
enterTransition = {
@ -2359,6 +2408,7 @@ private fun MainAppContent(
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
streamType = launch.streamType,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
@ -2386,14 +2436,6 @@ private fun MainAppContent(
ResumePromptRepository.markPlayerExitedNormally()
PlayerLaunchStore.remove(route.launchId)
navController.popBackStack()
launch.returnStreamLaunchId?.let { streamLaunchId ->
if (isIos && StreamLaunchStore.get(streamLaunchId) != null) {
navController.navigate(StreamRoute(launchId = streamLaunchId)) {
launchSingleTop = true
}
}
streamLaunchIdsPreservedForPlayerReturn.remove(streamLaunchId)
}
},
onOpenInExternalPlayer = { request ->
val playerLaunch = PlayerLaunch(
@ -2444,14 +2486,11 @@ private fun MainAppContent(
}
composable<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
val target = route.toCatalogTarget()
CatalogScreen(
title = route.title,
subtitle = route.subtitle,
manifestUrl = route.manifestUrl,
type = route.type,
catalogId = route.catalogId,
supportsPagination = route.supportsPagination,
genre = route.genre,
target = target,
onBack = {
CatalogRepository.clear()
navController.popBackStack()
@ -2461,11 +2500,11 @@ private fun MainAppContent(
},
onPosterLongClick = { meta ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedPosterActionTarget = if (route.manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) {
selectedPosterActionTarget = if (target is CatalogTarget.Library) {
PosterActionTarget(
preview = meta,
libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L),
libraryListKey = route.catalogId,
libraryListKey = target.sectionType,
)
} else {
PosterActionTarget(preview = meta)
@ -2988,23 +3027,24 @@ private fun TabletFloatingTopBar(
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = modifier
.fillMaxWidth()
.padding(top = statusBarPadding + 10.dp, bottom = 8.dp),
.padding(top = statusBarPadding + NuvioTokens.Space.s10, bottom = tokens.spacing.controlGap),
contentAlignment = Alignment.TopCenter,
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f),
shape = RoundedCornerShape(999.dp),
tonalElevation = 4.dp,
shadowElevation = 10.dp,
color = tokens.colors.surface.copy(alpha = tokens.opacity.visible - tokens.opacity.subtle),
shape = tokens.shapes.chip,
tonalElevation = tokens.elevation.playerControls,
shadowElevation = tokens.elevation.overlay,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s10, vertical = tokens.spacing.controlGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
TabletTopPillItem(
@ -3015,11 +3055,11 @@ private fun TabletFloatingTopBar(
Icon(
imageVector = Icons.Filled.Home,
contentDescription = stringResource(Res.string.compose_nav_home),
modifier = Modifier.size(18.dp),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Home) {
MaterialTheme.colorScheme.onPrimaryContainer
tokens.colors.textPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
tokens.colors.textMuted
},
)
},
@ -3032,11 +3072,11 @@ private fun TabletFloatingTopBar(
Icon(
painter = painterResource(Res.drawable.sidebar_search),
contentDescription = stringResource(Res.string.compose_nav_search),
modifier = Modifier.size(18.dp),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Search) {
MaterialTheme.colorScheme.onPrimaryContainer
tokens.colors.textPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
tokens.colors.textMuted
},
)
},
@ -3049,26 +3089,26 @@ private fun TabletFloatingTopBar(
Icon(
painter = painterResource(Res.drawable.sidebar_library),
contentDescription = stringResource(Res.string.compose_nav_library),
modifier = Modifier.size(18.dp),
modifier = Modifier.size(NuvioTokens.Space.s18),
tint = if (selectedTab == AppScreenTab.Library) {
MaterialTheme.colorScheme.onPrimaryContainer
tokens.colors.textPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
tokens.colors.textMuted
},
)
},
)
Surface(
color = if (selectedTab == AppScreenTab.Settings) {
MaterialTheme.colorScheme.primaryContainer
tokens.colors.overlaySelected
} else {
MaterialTheme.colorScheme.surface
tokens.colors.surface
},
shape = RoundedCornerShape(999.dp),
shape = tokens.shapes.chip,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = tokens.spacing.listGap, vertical = tokens.spacing.controlGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
ProfileSwitcherTab(
@ -3082,9 +3122,9 @@ private fun TabletFloatingTopBar(
modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) },
style = MaterialTheme.typography.labelLarge,
color = if (selectedTab == AppScreenTab.Settings) {
MaterialTheme.colorScheme.onPrimaryContainer
tokens.colors.textPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
tokens.colors.textMuted
},
)
}
@ -3104,15 +3144,16 @@ private fun TabletTopPillItem(
onClick: () -> Unit,
icon: @Composable () -> Unit,
) {
val tokens = MaterialTheme.nuvio
Surface(
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(999.dp),
tonalElevation = if (selected) 2.dp else 0.dp,
color = if (selected) tokens.colors.overlaySelected else tokens.colors.surface,
shape = tokens.shapes.chip,
tonalElevation = if (selected) tokens.elevation.raised else tokens.elevation.flat,
modifier = Modifier.clickable(onClick = onClick),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = tokens.components.chipHorizontalPadding, vertical = NuvioTokens.Space.s10),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
icon()
@ -3120,9 +3161,9 @@ private fun TabletTopPillItem(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) {
MaterialTheme.colorScheme.onPrimaryContainer
tokens.colors.textPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
tokens.colors.textMuted
},
)
}
@ -3133,10 +3174,11 @@ private fun TabletTopPillItem(
private fun AppLaunchOverlay(
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.zIndex(10f),
.background(tokens.colors.background)
.zIndex(NuvioTokens.Z.dialog),
contentAlignment = Alignment.Center,
) {
Column(
@ -3150,8 +3192,8 @@ private fun AppLaunchOverlay(
.height(44.dp),
contentScale = ContentScale.Fit,
)
Spacer(modifier = Modifier.height(24.dp))
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(tokens.spacing.sectionGap))
CircularProgressIndicator(color = tokens.colors.accent)
}
}
}

View file

@ -8,6 +8,8 @@ import nuvio.composeapp.generated.resources.action_resume
import nuvio.composeapp.generated.resources.action_resume_episode
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found
import nuvio.composeapp.generated.resources.compose_player_subtitle_lines_load_error
import nuvio.composeapp.generated.resources.continue_watching_up_next
import nuvio.composeapp.generated.resources.continue_watching_up_next_episode
import nuvio.composeapp.generated.resources.date_month_april
@ -40,6 +42,11 @@ import nuvio.composeapp.generated.resources.media_movie
import nuvio.composeapp.generated.resources.media_movies
import nuvio.composeapp.generated.resources.media_series
import nuvio.composeapp.generated.resources.media_tv
import nuvio.composeapp.generated.resources.p2p_error_unknown
import nuvio.composeapp.generated.resources.settings_stream_badge_enter_url
import nuvio.composeapp.generated.resources.settings_stream_badge_import_failed
import nuvio.composeapp.generated.resources.settings_stream_badge_import_limit
import nuvio.composeapp.generated.resources.settings_stream_badge_url_scheme_invalid
import nuvio.composeapp.generated.resources.unit_bytes_b
import nuvio.composeapp.generated.resources.unit_bytes_gb
import nuvio.composeapp.generated.resources.unit_bytes_kb
@ -134,6 +141,27 @@ fun localizedShortMonthName(month: Int): String =
else -> month.toString()
}
fun localizedNoSubtitleLinesFound(): String =
resourceString("No subtitle lines found") { getString(Res.string.compose_player_no_subtitle_lines_found) }
fun localizedSubtitleLinesLoadError(): String =
resourceString("Unable to load subtitle lines") { getString(Res.string.compose_player_subtitle_lines_load_error) }
fun localizedBadgeImportFailed(): String =
resourceString("Badge import failed.") { getString(Res.string.settings_stream_badge_import_failed) }
fun localizedBadgeEnterUrl(): String =
resourceString("Enter a badge JSON URL.") { getString(Res.string.settings_stream_badge_enter_url) }
fun localizedBadgeUrlSchemeInvalid(): String =
resourceString("Badge URL must start with http:// or https://.") { getString(Res.string.settings_stream_badge_url_scheme_invalid) }
fun localizedBadgeImportLimit(limit: Int): String =
resourceString("You can import up to $limit badge URLs.") { getString(Res.string.settings_stream_badge_import_limit, limit) }
fun localizedP2pUnknownTorrentError(): String =
resourceString("Unknown torrent error") { getString(Res.string.p2p_error_unknown) }
fun localizedByteUnit(unit: String): String =
when (unit) {
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }

View file

@ -1,3 +1,5 @@
package com.nuvio.app.core.sync
internal const val MOBILE_SYNC_PLATFORM = "mobile"
internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared"
internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv")

View file

@ -4,11 +4,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -17,6 +15,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -26,57 +26,23 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.isIos
import com.mohamedrejeb.calf.ui.sheet.AdaptiveBottomSheet
import com.mohamedrejeb.calf.ui.sheet.AdaptiveSheetState
import com.mohamedrejeb.calf.ui.sheet.rememberAdaptiveSheetState
typealias NuvioBottomSheetState = AdaptiveSheetState
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun rememberNuvioBottomSheetState(
skipPartiallyExpanded: Boolean = !isIos,
): NuvioBottomSheetState =
rememberAdaptiveSheetState(skipPartiallyExpanded = skipPartiallyExpanded)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NuvioModalBottomSheet(
onDismissRequest: () -> Unit,
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
shape: Shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
containerColor: Color = MaterialTheme.nuvio.colors.surfaceSheet,
contentColor: Color = MaterialTheme.nuvio.colors.textPrimary,
shape: Shape = RoundedCornerShape(topStart = NuvioTokens.Space.s28, topEnd = NuvioTokens.Space.s28),
showDragHandle: Boolean = true,
iosContentTopPadding: Dp = 28.dp,
content: @Composable ColumnScope.() -> Unit,
) {
val sheetModifier = if (isIos) {
modifier.fillMaxSize()
} else {
modifier
}
val sheetContent: @Composable ColumnScope.() -> Unit = {
if (isIos && iosContentTopPadding > 0.dp) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = iosContentTopPadding),
content = content,
)
} else {
content()
}
}
AdaptiveBottomSheet(
ModalBottomSheet(
onDismissRequest = onDismissRequest,
adaptiveSheetState = sheetState,
modifier = sheetModifier,
sheetState = sheetState,
modifier = modifier,
containerColor = containerColor,
contentColor = contentColor,
shape = shape,
@ -85,7 +51,7 @@ fun NuvioModalBottomSheet(
} else {
null
},
content = sheetContent,
content = content,
)
}
@ -95,7 +61,7 @@ fun NuvioBottomSheetDivider(
) {
HorizontalDivider(
modifier = modifier,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
color = MaterialTheme.nuvio.colors.borderSubtle,
)
}
@ -107,27 +73,28 @@ fun NuvioBottomSheetActionRow(
icon: ImageVector? = null,
trailingContent: (@Composable RowScope.() -> Unit)? = null,
) {
val tokens = MaterialTheme.nuvio
Row(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = tokens.spacing.screenHorizontal),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp),
tint = tokens.colors.accent,
modifier = Modifier.size(NuvioTokens.Icon.md),
)
}
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -137,7 +104,7 @@ fun NuvioBottomSheetActionRow(
@OptIn(ExperimentalMaterial3Api::class)
suspend fun dismissNuvioBottomSheet(
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
onDismiss: () -> Unit,
) {
if (sheetState.isVisible) {
@ -148,11 +115,12 @@ suspend fun dismissNuvioBottomSheet(
@Composable
private fun NuvioBottomSheetDragHandle() {
val tokens = MaterialTheme.nuvio
Box(
modifier = Modifier
.padding(top = 10.dp, bottom = 6.dp)
.size(width = 54.dp, height = 5.dp)
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
.padding(top = NuvioTokens.Space.s10, bottom = NuvioTokens.Space.s6)
.size(width = NuvioTokens.Space.s56 - NuvioTokens.Space.s2, height = NuvioTokens.Space.s5)
.clip(tokens.shapes.chip)
.background(tokens.colors.borderDefault),
)
}

View file

@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
@ -78,29 +77,30 @@ import kotlinx.coroutines.flow.asStateFlow
@Composable
fun NuvioScreen(
modifier: Modifier = Modifier,
horizontalPadding: Dp = 16.dp,
horizontalPadding: Dp = MaterialTheme.nuvio.spacing.screenHorizontal,
topPadding: Dp? = null,
listState: LazyListState = rememberLazyListState(),
content: LazyListScope.() -> Unit,
) {
val tokens = MaterialTheme.nuvio
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
LazyColumn(
state = listState,
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
.background(tokens.colors.background),
contentPadding = PaddingValues(
start = horizontalPadding,
top = topPadding ?: 10.dp + statusBarTop + nuvioPlatformExtraTopPadding,
top = topPadding ?: tokens.spacing.screenTop + statusBarTop + nuvioPlatformExtraTopPadding,
end = horizontalPadding,
bottom = nuvioSafeBottomPadding(18.dp),
bottom = nuvioSafeBottomPadding(tokens.spacing.screenBottom),
),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
content = content,
)
}
internal fun Modifier.nuvioBlockPointerPassthrough(): Modifier =
internal fun Modifier.nuvioConsumePointerEvents(): Modifier =
pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
@ -117,15 +117,16 @@ fun NuvioSurfaceCard(
tonalElevation: Int = 0,
content: @Composable ColumnScope.() -> Unit,
) {
val tokens = MaterialTheme.nuvio
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(24.dp),
color = tokens.colors.surface,
shape = tokens.shapes.card,
tonalElevation = tonalElevation.dp,
shadowElevation = 0.dp,
shadowElevation = tokens.elevation.flat,
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp),
modifier = Modifier.padding(tokens.spacing.cardPadding),
content = content,
)
}
@ -140,47 +141,56 @@ fun NuvioScreenHeader(
onBack: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
val tokens = MaterialTheme.nuvio
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else 0.dp
Row(
modifier = modifier
.fillMaxWidth()
.nuvioBlockPointerPassthrough()
.background(MaterialTheme.colorScheme.background)
.padding(top = resolvedTopPadding, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none
Box(
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.matchParentSize()
.background(tokens.colors.background)
.nuvioConsumePointerEvents(),
) {}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = MaterialTheme.colorScheme.onBackground,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(Res.string.action_back),
tint = tokens.colors.textPrimary,
)
}
}
AnimatedContent(
targetState = title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "screen_header_title",
) { currentTitle ->
Text(
text = currentTitle,
style = MaterialTheme.typography.displayLarge,
color = tokens.colors.textPrimary,
)
}
}
AnimatedContent(
targetState = title,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "screen_header_title",
) { currentTitle ->
Text(
text = currentTitle,
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2),
verticalAlignment = Alignment.CenterVertically,
content = actions,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
content = actions,
)
}
}
@ -193,7 +203,7 @@ fun NuvioSectionLabel(
text = text,
modifier = modifier,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.nuvio.colors.textMuted,
fontWeight = FontWeight.Bold,
)
}
@ -214,7 +224,7 @@ fun NuvioActionLabel(
}
),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
color = MaterialTheme.nuvio.colors.accent,
)
}
@ -223,14 +233,15 @@ fun NuvioIconActionButton(
icon: ImageVector,
contentDescription: String,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.onSurface,
tint: Color = MaterialTheme.nuvio.colors.textPrimary,
onClick: () -> Unit = {},
) {
val tokens = MaterialTheme.nuvio
IconButton(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.background.copy(alpha = 0.001f),
shape = CircleShape,
color = tokens.colors.background.copy(alpha = 0.001f),
shape = tokens.shapes.avatar,
),
onClick = onClick,
) {
@ -246,11 +257,11 @@ fun NuvioIconActionButton(
fun NuvioBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
containerColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
buttonSize: Dp = 40.dp,
iconSize: Dp = 22.dp,
shape: Shape = MaterialTheme.nuvio.shapes.avatar,
containerColor: Color = MaterialTheme.nuvio.colors.surface,
contentColor: Color = MaterialTheme.nuvio.colors.textPrimary,
buttonSize: Dp = NuvioTokens.Space.s40,
iconSize: Dp = NuvioTokens.Icon.md,
contentDescription: String = stringResource(Res.string.action_back),
) {
Box(
@ -277,18 +288,19 @@ fun NuvioPrimaryButton(
enabled: Boolean = true,
onClick: () -> Unit = {},
) {
val tokens = MaterialTheme.nuvio
Button(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(52.dp),
.height(NuvioTokens.Space.s48 + NuvioTokens.Space.s4),
enabled = enabled,
shape = RoundedCornerShape(16.dp),
shape = tokens.shapes.button,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.65f),
disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.65f),
containerColor = tokens.colors.accent,
contentColor = tokens.colors.onAccent,
disabledContainerColor = tokens.colors.accent.copy(alpha = tokens.opacity.disabled),
disabledContentColor = tokens.colors.onAccent.copy(alpha = tokens.opacity.disabled),
),
) {
AnimatedContent(
@ -313,27 +325,28 @@ fun NuvioInputField(
modifier: Modifier = Modifier,
trailingContent: (@Composable (() -> Unit))? = null,
) {
val tokens = MaterialTheme.nuvio
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(14.dp),
shape = RoundedCornerShape(NuvioTokens.Radius.lg),
placeholder = {
Text(
text = placeholder,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.bodyLarge,
)
},
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
textStyle = MaterialTheme.typography.bodyLarge.copy(color = tokens.colors.textPrimary),
trailingIcon = trailingContent,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.outline,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
cursorColor = MaterialTheme.colorScheme.primary,
focusedBorderColor = tokens.colors.borderFocus,
unfocusedBorderColor = tokens.colors.borderDefault,
focusedContainerColor = tokens.colors.surfaceCard,
unfocusedContainerColor = tokens.colors.surfaceCard,
cursorColor = tokens.colors.accent,
),
)
}
@ -343,18 +356,19 @@ fun NuvioInfoBadge(
text: String,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
Box(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(999.dp),
color = tokens.colors.surfaceCard,
shape = tokens.shapes.chip,
)
.padding(horizontal = 10.dp, vertical = 6.dp),
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s6),
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -374,13 +388,13 @@ fun NuvioInlineMetadata(
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.nuvio.colors.textMuted,
)
Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(NuvioTokens.Space.s6))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.nuvio.colors.textPrimary,
)
}
}
@ -399,6 +413,7 @@ fun NuvioStatusModal(
onDismiss: (() -> Unit)? = null,
) {
if (!isVisible) return
val tokens = MaterialTheme.nuvio
BasicAlertDialog(
onDismissRequest = {
@ -409,31 +424,31 @@ fun NuvioStatusModal(
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(24.dp),
color = tokens.colors.surfaceDialog,
shape = tokens.shapes.dialog,
) {
Column(
modifier = Modifier.padding(20.dp),
modifier = Modifier.padding(tokens.spacing.dialogPadding),
) {
if (isBusy) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.5.dp,
color = tokens.colors.accent,
strokeWidth = NuvioTokens.Border.medium + NuvioTokens.Space.hairline,
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(NuvioTokens.Space.s16))
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(tokens.spacing.controlGap))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
)
Spacer(modifier = Modifier.height(18.dp))
Spacer(modifier = Modifier.height(NuvioTokens.Space.s18))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
@ -441,20 +456,20 @@ fun NuvioStatusModal(
if (!isBusy && dismissText != null && onDismiss != null) {
Button(
onClick = onDismiss,
shape = RoundedCornerShape(16.dp),
shape = tokens.shapes.button,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
containerColor = tokens.colors.surfaceCard,
contentColor = tokens.colors.textPrimary,
),
) {
Text(dismissText)
}
Spacer(modifier = Modifier.width(10.dp))
Spacer(modifier = Modifier.width(NuvioTokens.Space.s10))
}
Button(
onClick = onConfirm,
enabled = !isBusy,
shape = RoundedCornerShape(16.dp),
shape = tokens.shapes.button,
) {
Text(confirmText)
}
@ -468,6 +483,7 @@ fun NuvioStatusModal(
fun NuvioToastHost(
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val toast by NuvioToastController.currentToast.collectAsState()
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val visibilityState = remember { MutableTransitionState(false) }
@ -505,21 +521,21 @@ fun NuvioToastHost(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = statusBarTop + 12.dp)
.padding(horizontal = 16.dp),
.padding(top = statusBarTop + tokens.spacing.listGap)
.padding(horizontal = tokens.spacing.screenHorizontal),
contentAlignment = Alignment.TopCenter,
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
shadowElevation = 10.dp,
shape = RoundedCornerShape(NuvioTokens.Radius.xl),
color = tokens.colors.surfacePopover,
tonalElevation = tokens.elevation.raised,
shadowElevation = tokens.elevation.overlay,
) {
Text(
text = currentToast.message,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s16, vertical = NuvioTokens.Space.s12),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
}
}

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Info
@ -17,6 +16,7 @@ import androidx.compose.material.icons.filled.Replay
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -51,7 +51,8 @@ fun NuvioContinueWatchingActionSheet(
onRemove: () -> Unit,
) {
if (item == null) return
val sheetState = rememberNuvioBottomSheetState()
val tokens = MaterialTheme.nuvio
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
fun dismissAfter(action: () -> Unit) {
@ -72,7 +73,7 @@ fun NuvioContinueWatchingActionSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
.padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)),
) {
ContinueWatchingSheetHeader(item = item)
if (showDetailsOption) {
@ -114,19 +115,20 @@ private fun ContinueWatchingSheetHeader(
item: ContinueWatchingItem,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(width = 64.dp, height = 92.dp)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
.size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
val artwork = item.poster ?: item.imageUrl
@ -140,9 +142,9 @@ private fun ContinueWatchingSheetHeader(
} else {
Text(
text = item.title,
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(tokens.spacing.listGap),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
@ -151,12 +153,12 @@ private fun ContinueWatchingSheetHeader(
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
) {
Text(
text = item.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -164,7 +166,7 @@ private fun ContinueWatchingSheetHeader(
Text(
text = localizedContinueWatchingSubtitle(item),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)

View file

@ -11,14 +11,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -29,7 +30,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
data class NuvioDropdownOption(
@ -48,14 +48,15 @@ fun NuvioDropdownChip(
onSelected: (NuvioDropdownOption) -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
var isSheetVisible by remember { mutableStateOf(false) }
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
Row(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.clip(tokens.shapes.compactCard)
.background(tokens.colors.surface)
.then(
if (enabled) {
Modifier.clickable { isSheetVisible = true }
@ -63,22 +64,22 @@ fun NuvioDropdownChip(
Modifier
},
)
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
.padding(horizontal = NuvioTokens.Space.s12, vertical = tokens.components.chipVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
color = if (enabled) tokens.colors.textPrimary else tokens.colors.textDisabled,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
modifier = Modifier.size(NuvioTokens.Icon.sm + NuvioTokens.Space.s2),
tint = if (enabled) tokens.colors.textMuted else tokens.colors.borderDefault,
)
}
@ -115,10 +116,11 @@ private fun NuvioDropdownOptionsSheet(
title: String,
options: List<NuvioDropdownOption>,
selectedKey: String?,
sheetState: NuvioBottomSheetState,
sheetState: SheetState,
onDismiss: () -> Unit,
onSelected: (NuvioDropdownOption) -> Unit,
) {
val tokens = MaterialTheme.nuvio
NuvioModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
@ -126,19 +128,19 @@ private fun NuvioDropdownOptionsSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
.padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)),
) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
modifier = Modifier.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
NuvioBottomSheetDivider()
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 420.dp),
.heightIn(max = tokens.breakpoints.largePhone),
) {
itemsIndexed(options) { index, option ->
NuvioBottomSheetActionRow(
@ -149,8 +151,8 @@ private fun NuvioDropdownOptionsSheet(
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
tint = tokens.colors.accent,
modifier = Modifier.size(tokens.icons.md),
)
}
},

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.PlayArrow
@ -73,6 +72,7 @@ fun NuvioFloatingPrompt(
modifier: Modifier = Modifier,
autoDismissMs: Long = AutoDismissDelayMs,
) {
val tokens = MaterialTheme.nuvio
val visibilityState = remember { MutableTransitionState(false) }
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
@ -98,12 +98,12 @@ fun NuvioFloatingPrompt(
LaunchedEffect(Unit) {
delay(autoDismissMs)
val dismissDistance = maxOf(
promptHeightPx.toFloat() + with(density) { 24.dp.toPx() },
with(density) { 160.dp.toPx() },
promptHeightPx.toFloat() + with(density) { tokens.spacing.sectionGap.toPx() },
with(density) { (NuvioTokens.Space.s80 + NuvioTokens.Space.s80).toPx() },
)
dragOffsetY.animateTo(
targetValue = dismissDistance,
animationSpec = tween(durationMillis = 240),
animationSpec = tween(durationMillis = tokens.motion.normalMillis),
)
onDismiss()
}
@ -116,14 +116,14 @@ fun NuvioFloatingPrompt(
AnimatedVisibility(
visibleState = visibilityState,
modifier = modifier,
enter = fadeIn(tween(400)) + slideInVertically(tween(400)) { it },
exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { it },
enter = fadeIn(tween(tokens.motion.slowMillis)) + slideInVertically(tween(tokens.motion.slowMillis)) { it },
exit = fadeOut(tween(tokens.motion.sheetEnterMillis)) + slideOutVertically(tween(tokens.motion.sheetEnterMillis)) { it },
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = navBarBottom + 72.dp)
.padding(horizontal = 16.dp)
.padding(bottom = navBarBottom + NuvioTokens.Space.s72)
.padding(horizontal = tokens.spacing.screenHorizontal)
.offset { IntOffset(0, dragOffsetY.value.roundToInt().coerceAtLeast(0)) }
.pointerInput(Unit) {
detectVerticalDragGestures(
@ -133,18 +133,18 @@ fun NuvioFloatingPrompt(
if (shouldDismiss) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
val dismissDistance = maxOf(
promptHeightPx.toFloat() + with(density) { 24.dp.toPx() },
with(density) { 160.dp.toPx() },
promptHeightPx.toFloat() + with(density) { tokens.spacing.sectionGap.toPx() },
with(density) { (NuvioTokens.Space.s80 + NuvioTokens.Space.s80).toPx() },
)
dragOffsetY.animateTo(
targetValue = dismissDistance,
animationSpec = tween(durationMillis = 220),
animationSpec = tween(durationMillis = tokens.motion.normalMillis),
)
onDismiss()
} else {
dragOffsetY.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 180),
animationSpec = tween(durationMillis = tokens.motion.fastMillis),
)
}
}
@ -153,7 +153,7 @@ fun NuvioFloatingPrompt(
coroutineScope.launch {
dragOffsetY.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 180),
animationSpec = tween(durationMillis = tokens.motion.fastMillis),
)
}
},
@ -167,27 +167,27 @@ fun NuvioFloatingPrompt(
) {
Surface(
modifier = Modifier.onSizeChanged { promptHeightPx = it.height },
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
shape = tokens.shapes.card,
color = tokens.colors.surfacePopover,
tonalElevation = tokens.elevation.playerControls,
shadowElevation = tokens.elevation.modal,
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(tokens.spacing.cardPaddingCompact),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 2.dp),
.padding(start = NuvioTokens.Space.s2),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Box(
modifier = Modifier
.size(width = 54.dp, height = 78.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
.size(width = NuvioTokens.Space.s56 - NuvioTokens.Space.s2, height = NuvioTokens.Space.s80 - NuvioTokens.Space.s2)
.clip(tokens.shapes.compactCard)
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
@ -207,37 +207,37 @@ fun NuvioFloatingPrompt(
Text(
text = stringResource(Res.string.floating_prompt_continue_where_left_off),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
)
Text(
text = title,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box(
modifier = Modifier.size(42.dp),
modifier = Modifier.size(NuvioTokens.Space.s40 + NuvioTokens.Space.s2),
contentAlignment = Alignment.Center,
) {
FilledIconButton(
onClick = actionWithHaptic,
modifier = Modifier.size(42.dp),
shape = CircleShape,
modifier = Modifier.size(NuvioTokens.Space.s40 + NuvioTokens.Space.s2),
shape = tokens.shapes.avatar,
) {
Icon(
imageVector = Icons.Rounded.PlayArrow,
contentDescription = actionLabel,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(tokens.icons.md),
)
}
}
@ -246,21 +246,21 @@ fun NuvioFloatingPrompt(
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f))
.height(8.dp)
.padding(1.dp),
.clip(tokens.shapes.chip)
.background(tokens.colors.playerTimelineTrack)
.height(NuvioTokens.Space.s8)
.padding(tokens.borders.thin),
) {
Box(
modifier = Modifier
.fillMaxWidth(progressFraction.coerceIn(0f, 1f))
.height(6.dp)
.clip(RoundedCornerShape(999.dp)),
.height(NuvioTokens.Space.s6)
.clip(tokens.shapes.chip),
) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.primary),
.background(tokens.colors.playerTimelineFill),
)
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -22,7 +21,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@ -31,17 +29,18 @@ fun NuvioNavigationBar(
modifier: Modifier = Modifier,
content: @Composable NuvioNavigationBarScope.() -> Unit,
) {
val tokens = MaterialTheme.nuvio
Column(modifier.fillMaxWidth()) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
thickness = tokens.borders.hairline,
color = tokens.colors.borderDefault,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(nuvioBottomNavigationBarInsets().asPaddingValues())
.padding(horizontal = 4.dp, vertical = nuvioBottomNavigationExtraVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
.padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally),
) {
NuvioNavigationBarScopeImpl(this).content()
}
@ -88,25 +87,25 @@ private class NuvioNavigationBarScopeImpl(
contentDescription: String?,
modifier: Modifier,
) {
val tokens = MaterialTheme.nuvio
val iconColor by animateColorAsState(
targetValue = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
)
with(rowScope) {
Icon(
modifier = modifier
.widthIn(max = 150.dp)
.widthIn(max = tokens.components.navItemMaxWidth)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.clip(tokens.components.navItemShape)
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp)
.size(28.dp),
.padding(NuvioTokens.Space.s10)
.size(tokens.components.navIconSize),
imageVector = icon,
contentDescription = contentDescription,
tint = iconColor,
@ -122,25 +121,25 @@ private class NuvioNavigationBarScopeImpl(
contentDescription: String?,
modifier: Modifier,
) {
val tokens = MaterialTheme.nuvio
val iconColor by animateColorAsState(
targetValue = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
)
with(rowScope) {
Icon(
modifier = modifier
.widthIn(max = 150.dp)
.widthIn(max = tokens.components.navItemMaxWidth)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.clip(tokens.components.navItemShape)
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp)
.size(28.dp),
.padding(NuvioTokens.Space.s10)
.size(tokens.components.navIconSize),
painter = painterResource(icon),
contentDescription = contentDescription,
tint = iconColor,
@ -155,20 +154,21 @@ private class NuvioNavigationBarScopeImpl(
modifier: Modifier,
content: @Composable () -> Unit,
) {
val tokens = MaterialTheme.nuvio
with(rowScope) {
Box(
modifier = modifier
.widthIn(max = 150.dp)
.widthIn(max = tokens.components.navItemMaxWidth)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.clip(tokens.components.navItemShape)
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp),
.padding(NuvioTokens.Space.s10),
contentAlignment = Alignment.Center,
) {
content()

View file

@ -6,7 +6,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.messageForEmptyState
import com.nuvio.app.core.network.titleForEmptyState
@ -20,20 +19,21 @@ fun NuvioNetworkOfflineCard(
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null,
) {
val tokens = MaterialTheme.nuvio
NuvioSurfaceCard(modifier = modifier) {
Text(
text = condition.titleForEmptyState(),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(tokens.spacing.controlGap))
Text(
text = condition.messageForEmptyState(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(tokens.spacing.screenHorizontal))
NuvioPrimaryButton(
text = stringResource(Res.string.action_retry),
onClick = onRetry,

View file

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@ -23,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -56,7 +56,8 @@ fun NuvioPosterActionSheet(
onToggleWatched: () -> Unit,
) {
if (item == null) return
val sheetState = rememberNuvioBottomSheetState()
val tokens = MaterialTheme.nuvio
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
@ -73,7 +74,7 @@ fun NuvioPosterActionSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
.padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)),
) {
PosterSheetHeader(item = item)
NuvioBottomSheetDivider()
@ -120,18 +121,19 @@ fun NuvioPosterActionSheet(
fun NuvioWatchedBadge(
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
Box(
modifier = modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
.size(NuvioTokens.Icon.md)
.clip(tokens.shapes.avatar)
.background(tokens.colors.accent),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = stringResource(Res.string.episodes_cd_watched),
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(12.dp),
tint = tokens.colors.onAccent,
modifier = Modifier.size(NuvioTokens.Icon.xs),
)
}
}
@ -155,7 +157,7 @@ fun NuvioAnimatedWatchedBadge(
fun BoxScope.NuvioPosterWatchedOverlay(
isWatched: Boolean,
modifier: Modifier = Modifier,
padding: Dp = 6.dp,
padding: Dp = NuvioTokens.Space.s6,
) {
NuvioAnimatedWatchedBadge(
isVisible = isWatched,
@ -170,19 +172,20 @@ private fun PosterSheetHeader(
item: MetaPreview,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(width = 64.dp, height = 92.dp)
.size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
if (item.poster != null) {
@ -195,9 +198,9 @@ private fun PosterSheetHeader(
} else {
Text(
text = item.name,
modifier = Modifier.padding(12.dp),
modifier = Modifier.padding(tokens.spacing.listGap),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
@ -206,12 +209,12 @@ private fun PosterSheetHeader(
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
) {
Text(
text = item.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@ -220,9 +223,9 @@ private fun PosterSheetHeader(
text = item.releaseInfo?.takeIf { it.isNotBlank() }?.let { formatReleaseDateForDisplay(it) }
?: item.type.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
},
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View file

@ -5,37 +5,36 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun NuvioProgressBar(
progress: Float,
modifier: Modifier = Modifier,
height: Dp = 4.dp,
trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
fillColor: Color = MaterialTheme.colorScheme.primary,
height: Dp = NuvioTokens.Space.s4,
trackColor: Color = MaterialTheme.nuvio.colors.playerTimelineTrack,
fillColor: Color = MaterialTheme.nuvio.colors.playerTimelineFill,
) {
val tokens = MaterialTheme.nuvio
val clampedProgress = progress.coerceIn(0f, 1f)
Box(
modifier = modifier
.fillMaxWidth()
.height(height)
.clip(RoundedCornerShape(percent = 50))
.clip(tokens.shapes.chip)
.background(trackColor),
) {
Box(
modifier = Modifier
.fillMaxWidth(clampedProgress)
.width(0.dp)
.width(NuvioTokens.Space.none)
.height(height)
.clip(RoundedCornerShape(percent = 50))
.clip(tokens.shapes.chip)
.background(fillColor),
)
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyRow
@ -26,8 +27,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -63,9 +66,10 @@ fun <T> NuvioShelfSection(
key: ((T) -> Any)? = null,
itemContent: @Composable (T) -> Unit,
) {
val tokens = MaterialTheme.nuvio
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
) {
if (title.isNotBlank()) {
NuvioShelfSectionHeader(
@ -111,6 +115,7 @@ fun NuvioPosterCard(
onLongClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val tokens = MaterialTheme.nuvio
val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp)
val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)
val catalogLogoOverlaySize = catalogLogoOverlaySize(
@ -121,14 +126,14 @@ fun NuvioPosterCard(
Column(
modifier = modifier.width(cardWidth),
verticalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(shape.aspectRatio)
.clip(cardShape)
.background(MaterialTheme.colorScheme.surface)
.background(tokens.colors.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
contentAlignment = Alignment.Center,
) {
@ -142,9 +147,9 @@ fun NuvioPosterCard(
} else {
Text(
text = title,
modifier = Modifier.padding(horizontal = 14.dp),
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s14),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
textAlign = TextAlign.Center,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
@ -155,7 +160,7 @@ fun NuvioPosterCard(
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 10.dp, vertical = 10.dp),
.padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10),
) {
if (!bottomLeftLogoUrl.isNullOrBlank()) {
AsyncImage(
@ -170,7 +175,7 @@ fun NuvioPosterCard(
Text(
text = bottomLeftText.orEmpty(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimary,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = catalogLogoOverlaySize.textMaxWidth),
@ -185,7 +190,7 @@ fun NuvioPosterCard(
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -193,15 +198,15 @@ fun NuvioPosterCard(
Text(
text = detailLine,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Box(modifier = Modifier.height(0.dp))
Box(modifier = Modifier.height(NuvioTokens.Space.none))
}
} else {
Box(modifier = Modifier.height(0.dp))
Box(modifier = Modifier.height(NuvioTokens.Space.none))
}
}
}
@ -214,38 +219,46 @@ private fun NuvioShelfSectionHeader(
onViewAllClick: (() -> Unit)? = null,
viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default,
) {
Row(
val tokens = MaterialTheme.nuvio
Column(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.weight(1f),
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
color = tokens.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (showAccent) {
Box(
modifier = Modifier
.padding(top = 6.dp)
.width(60.dp)
.height(4.dp)
.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(999.dp),
),
)
val viewAllPlaceholderModifier = if (onViewAllClick == null) {
Modifier
.alpha(0f)
.clearAndSetSemantics { }
} else {
Modifier
}
}
if (onViewAllClick != null) {
NuvioViewAllPill(
onClick = onViewAllClick,
size = viewAllPillSize,
modifier = viewAllPlaceholderModifier,
)
}
if (showAccent) {
Box(
modifier = Modifier
.padding(top = NuvioTokens.Space.s6)
.width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4)
.height(NuvioTokens.Space.s4)
.background(
color = tokens.colors.accent,
shape = tokens.shapes.chip,
),
)
}
}
@ -255,39 +268,28 @@ private fun NuvioShelfSectionHeader(
private fun NuvioViewAllPill(
onClick: (() -> Unit)?,
size: NuvioViewAllPillSize,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
val isAmoled = colorScheme.background == androidx.compose.ui.graphics.Color.Black && colorScheme.surface == androidx.compose.ui.graphics.Color(0xFF050505)
val horizontalPadding = if (size == NuvioViewAllPillSize.Compact) 12.dp else 18.dp
val verticalPadding = if (size == NuvioViewAllPillSize.Compact) 9.dp else 14.dp
val textStyle = if (size == NuvioViewAllPillSize.Compact) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.titleMedium
}
val iconSpacing = if (size == NuvioViewAllPillSize.Compact) 2.dp else 4.dp
val tokens = MaterialTheme.nuvio
val actionSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s32 else NuvioTokens.Space.s40
val iconSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md
val viewAllText = stringResource(Res.string.home_view_all)
Row(
modifier = Modifier
Box(
modifier = modifier
.size(actionSize)
.background(
color = if (isAmoled) androidx.compose.ui.graphics.Color(0xFF0D0D0D) else colorScheme.surface,
shape = RoundedCornerShape(20.dp),
color = tokens.colors.surface,
shape = RoundedCornerShape(NuvioTokens.Radius.xl),
)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.spacedBy(iconSpacing),
verticalAlignment = Alignment.CenterVertically,
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.home_view_all),
style = textStyle,
color = MaterialTheme.colorScheme.onSurface,
)
Icon(
imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.height(if (size == NuvioViewAllPillSize.Compact) 16.dp else 20.dp),
contentDescription = viewAllText,
tint = tokens.colors.textMuted,
modifier = Modifier.size(iconSize),
)
}
}

View file

@ -18,7 +18,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.jetbrains_sans_bold
import nuvio.composeapp.generated.resources.jetbrains_sans_regular
@ -53,24 +52,6 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false
onError = Color(0xFFFCE5EC),
)
private val NuvioDarkColors = darkColorScheme(
primary = Color(0xFF2E86B8),
onPrimary = Color(0xFFD2E8F7),
primaryContainer = Color(0xFF102531),
onPrimaryContainer = Color(0xFFE2F1FA),
secondary = Color(0xFF8A929C),
onSecondary = Color(0xFFEEF1F3),
background = Color(0xFF020404),
onBackground = Color(0xFFF5F7F8),
surface = Color(0xFF0A0D0D),
onSurface = Color(0xFFF5F7F8),
surfaceVariant = Color(0xFF121616),
onSurfaceVariant = Color(0xFF969CA3),
outline = Color(0xFF252A2A),
error = Color(0xFFE36A8A),
onError = Color(0xFFFCE5EC),
)
private val JetBrainsSans: FontFamily
@Composable
get() = FontFamily(
@ -84,54 +65,54 @@ private val NuvioTypography: Typography
get() = Typography(
displayLarge = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 38.sp,
lineHeight = 42.sp,
fontSize = NuvioTokens.Type.pageDisplay,
lineHeight = NuvioTokens.LineHeight.pageDisplay,
fontWeight = FontWeight.Bold,
letterSpacing = (-1.2).sp,
letterSpacing = NuvioTokens.LetterSpacing.pageDisplay,
),
headlineLarge = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 26.sp,
lineHeight = 30.sp,
fontSize = NuvioTokens.Type.headline,
lineHeight = NuvioTokens.LineHeight.headline,
fontWeight = FontWeight.SemiBold,
letterSpacing = (-0.8).sp,
letterSpacing = NuvioTokens.LetterSpacing.headline,
),
titleLarge = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 18.sp,
lineHeight = 24.sp,
fontSize = NuvioTokens.Type.titleSm,
lineHeight = NuvioTokens.LineHeight.materialTitleLarge,
fontWeight = FontWeight.SemiBold,
),
titleMedium = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 16.sp,
lineHeight = 20.sp,
fontSize = NuvioTokens.Type.bodyLg,
lineHeight = NuvioTokens.LineHeight.bodyMd,
fontWeight = FontWeight.SemiBold,
),
bodyLarge = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 15.sp,
lineHeight = 22.sp,
fontSize = NuvioTokens.Type.bodyApp,
lineHeight = NuvioTokens.LineHeight.bodyApp,
fontWeight = FontWeight.Normal,
),
bodyMedium = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 14.sp,
lineHeight = 20.sp,
fontSize = NuvioTokens.Type.bodyMd,
lineHeight = NuvioTokens.LineHeight.bodyMd,
fontWeight = FontWeight.Normal,
),
labelLarge = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 14.sp,
lineHeight = 18.sp,
fontSize = NuvioTokens.Type.bodyMd,
lineHeight = NuvioTokens.LineHeight.bodySm,
fontWeight = FontWeight.SemiBold,
),
labelMedium = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 12.sp,
lineHeight = 14.sp,
fontSize = NuvioTokens.Type.labelSm,
lineHeight = NuvioTokens.LineHeight.labelXs,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.8.sp,
letterSpacing = NuvioTokens.LetterSpacing.label,
),
)
@ -140,62 +121,62 @@ private val NuvioTypeTokens: NuvioTypeScale
get() = NuvioTypeScale(
labelXs = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 11.sp,
lineHeight = 14.sp,
fontSize = NuvioTokens.Type.labelXs,
lineHeight = NuvioTokens.LineHeight.labelXs,
fontWeight = FontWeight.SemiBold,
),
labelSm = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 12.sp,
lineHeight = 15.sp,
fontSize = NuvioTokens.Type.labelSm,
lineHeight = NuvioTokens.LineHeight.labelSm,
fontWeight = FontWeight.SemiBold,
),
bodySm = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 13.sp,
lineHeight = 18.sp,
fontSize = NuvioTokens.Type.bodySm,
lineHeight = NuvioTokens.LineHeight.bodySm,
fontWeight = FontWeight.Normal,
),
bodyMd = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 14.sp,
lineHeight = 20.sp,
fontSize = NuvioTokens.Type.bodyMd,
lineHeight = NuvioTokens.LineHeight.bodyMd,
fontWeight = FontWeight.Normal,
),
bodyLg = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 16.sp,
lineHeight = 22.sp,
fontSize = NuvioTokens.Type.bodyLg,
lineHeight = NuvioTokens.LineHeight.bodyLg,
fontWeight = FontWeight.Normal,
),
titleSm = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 18.sp,
lineHeight = 22.sp,
fontSize = NuvioTokens.Type.titleSm,
lineHeight = NuvioTokens.LineHeight.titleSm,
fontWeight = FontWeight.SemiBold,
),
titleMd = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 22.sp,
lineHeight = 26.sp,
fontSize = NuvioTokens.Type.titleMd,
lineHeight = NuvioTokens.LineHeight.titleMd,
fontWeight = FontWeight.SemiBold,
),
titleLg = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 28.sp,
lineHeight = 32.sp,
fontSize = NuvioTokens.Type.titleLg,
lineHeight = NuvioTokens.LineHeight.titleLg,
fontWeight = FontWeight.SemiBold,
),
displaySm = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 32.sp,
lineHeight = 36.sp,
fontSize = NuvioTokens.Type.displaySm,
lineHeight = NuvioTokens.LineHeight.displaySm,
fontWeight = FontWeight.Bold,
),
displayMd = TextStyle(
fontFamily = JetBrainsSans,
fontSize = 48.sp,
lineHeight = 52.sp,
fontSize = NuvioTokens.Type.displayMd,
lineHeight = NuvioTokens.LineHeight.displayMd,
fontWeight = FontWeight.Bold,
),
)
@ -211,7 +192,9 @@ fun NuvioTheme(
amoled: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = buildColorScheme(ThemeColors.getColorPalette(appTheme), amoled = amoled)
val palette = ThemeColors.getColorPalette(appTheme)
val colorScheme = buildColorScheme(palette, amoled = amoled)
val tokens = defaultNuvioThemeTokens(palette, amoled = amoled, colorScheme = colorScheme)
val density = LocalDensity.current
CompositionLocalProvider(
@ -219,6 +202,7 @@ fun NuvioTheme(
density = density.density,
fontScale = 1f,
),
LocalNuvioThemeTokens provides tokens,
LocalNuvioTypeScale provides NuvioTypeTokens,
LocalRippleConfiguration provides NuvioRippleConfiguration,
LocalAppTheme provides appTheme,

View file

@ -0,0 +1,85 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@Composable
fun NuvioTokenPreviewSurface(
modifier: Modifier = Modifier,
themes: List<AppTheme> = AppTheme.entries,
) {
LazyColumn(
modifier = modifier.background(MaterialTheme.nuvio.colors.background),
contentPadding = PaddingValues(MaterialTheme.nuvio.spacing.screenHorizontal),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.sectionGap),
) {
items(themes.size) { index ->
val theme = themes[index]
NuvioTheme(appTheme = theme) {
TokenPreviewThemeSection(theme = theme)
}
}
}
}
@Composable
private fun TokenPreviewThemeSection(theme: AppTheme) {
val tokens = MaterialTheme.nuvio
Column(
modifier = Modifier
.fillMaxWidth()
.clip(tokens.shapes.card)
.background(tokens.colors.surface)
.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.card)
.padding(tokens.spacing.cardPadding),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Text(
text = theme.name,
style = MaterialTheme.nuvioTypeScale.titleMd,
color = tokens.colors.textPrimary,
)
Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) {
listOf(
tokens.colors.background,
tokens.colors.surface,
tokens.colors.surfaceCard,
tokens.colors.accent,
tokens.colors.success,
tokens.colors.warning,
tokens.colors.danger,
).forEach { color ->
Box(
modifier = Modifier
.size(NuvioTokens.Icon.xl)
.clip(RoundedCornerShape(NuvioTokens.Radius.sm))
.background(color)
.border(tokens.borders.hairline, tokens.colors.borderDefault, RoundedCornerShape(NuvioTokens.Radius.sm)),
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(NuvioTokens.Space.s8)
.clip(tokens.shapes.chip)
.background(tokens.colors.accent),
)
}
}

View file

@ -0,0 +1,526 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object NuvioTokens {
object Space {
val none = 0.dp
val hairline = 0.5.dp
val s1 = 1.dp
val s2 = 2.dp
val s3 = 3.dp
val s4 = 4.dp
val s5 = 5.dp
val s6 = 6.dp
val s7 = 7.dp
val s8 = 8.dp
val s10 = 10.dp
val s12 = 12.dp
val s14 = 14.dp
val s16 = 16.dp
val s18 = 18.dp
val s20 = 20.dp
val s22 = 22.dp
val s24 = 24.dp
val s28 = 28.dp
val s32 = 32.dp
val s36 = 36.dp
val s40 = 40.dp
val s48 = 48.dp
val s56 = 56.dp
val s64 = 64.dp
val s72 = 72.dp
val s80 = 80.dp
val s96 = 96.dp
}
object Radius {
val none = Space.none
val xs = Space.s4
val sm = Space.s6
val md = Space.s8
val lg = Space.s12
val xl = Space.s16
val xxl = Space.s24
val full = 999.dp
val card = xxl
val compactCard = lg
val sheet = xxl
val dialog = xxl
val button = xl
val chip = full
val poster = lg
val avatar = full
val playerPanel = xxl
}
object Border {
val hairline = Space.hairline
val thin = Space.s1
val medium = Space.s2
}
object Elevation {
val none = Space.none
val raised = Space.s2
val modal = Space.s8
val overlay = Space.s12
val playerControls = Space.s4
}
object Opacity {
const val invisible = 0f
const val disabled = 0.38f
const val secondary = 0.70f
const val muted = 0.60f
const val selected = 0.15f
const val hover = 0.08f
const val pressed = 0.12f
const val subtle = 0.06f
const val medium = 0.52f
const val strong = 0.75f
const val overlayLight = 0.35f
const val overlayMedium = 0.56f
const val overlayHeavy = 0.82f
const val visible = 1f
}
object Motion {
const val instantMillis = 0
const val fastMillis = 150
const val normalMillis = 220
const val sheetEnterMillis = 300
const val sheetExitMillis = 250
const val slowMillis = 400
const val cinematicMillis = 700
val standard: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)
val decelerate: Easing = CubicBezierEasing(0f, 0f, 0f, 1f)
val accelerate: Easing = CubicBezierEasing(0.3f, 0f, 1f, 1f)
}
object Icon {
val xs = Space.s12
val sm = Space.s16
val md = Space.s20
val lg = Space.s24
val xl = Space.s32
val xxl = Space.s40
}
object Type {
val labelXs = 11.sp
val labelSm = 12.sp
val bodySm = 13.sp
val bodyMd = 14.sp
val bodyApp = 15.sp
val bodyLg = 16.sp
val titleSm = 18.sp
val headline = 26.sp
val titleMd = 22.sp
val titleLg = 28.sp
val displaySm = 32.sp
val pageDisplay = 38.sp
val displayMd = 48.sp
}
object LineHeight {
val labelXs = 14.sp
val labelSm = 15.sp
val bodySm = 18.sp
val bodyMd = 20.sp
val bodyApp = 22.sp
val bodyLg = 22.sp
val titleSm = 22.sp
val materialTitleLarge = 24.sp
val headline = 30.sp
val titleMd = 26.sp
val titleLg = 32.sp
val displaySm = 36.sp
val pageDisplay = 42.sp
val displayMd = 52.sp
}
object LetterSpacing {
val none = 0.sp
val pageDisplay = (-1.2).sp
val headline = (-0.8).sp
val label = 0.8.sp
}
object Breakpoint {
val phone = 0.dp
val largePhone = 420.dp
val tablet = 600.dp
val largeTablet = 840.dp
val desktop = 1024.dp
val playerWide = 1280.dp
}
object Z {
const val base = 0f
const val stickyHeader = 2f
const val navigation = 4f
const val sheet = 8f
const val dialog = 10f
const val playerOverlay = 12f
const val toast = 16f
}
}
@Immutable
data class NuvioColorTokens(
val background: Color,
val backgroundAmoled: Color,
val surface: Color,
val surfaceElevated: Color,
val surfaceCard: Color,
val surfaceSheet: Color,
val surfaceDialog: Color,
val surfacePopover: Color,
val nativeChrome: Color,
val textPrimary: Color,
val textSecondary: Color,
val textMuted: Color,
val textDisabled: Color,
val textInverse: Color,
val accent: Color,
val accentStrong: Color,
val onAccent: Color,
val focusRing: Color,
val focusBackground: Color,
val borderSubtle: Color,
val borderDefault: Color,
val borderStrong: Color,
val borderFocus: Color,
val borderSelected: Color,
val success: Color,
val warning: Color,
val danger: Color,
val info: Color,
val neutral: Color,
val overlayScrim: Color,
val overlayHover: Color,
val overlayPressed: Color,
val overlaySelected: Color,
val overlayDisabled: Color,
val shimmer: Color,
val skeleton: Color,
val playerControlsBackground: Color,
val playerControlsForeground: Color,
val playerTimelineTrack: Color,
val playerTimelineFill: Color,
val playerSubtitlePreview: Color,
val playerBuffering: Color,
val parentalGuide: Color,
)
@Immutable
data class NuvioShapeTokens(
val card: Shape,
val compactCard: Shape,
val sheet: Shape,
val dialog: Shape,
val button: Shape,
val chip: Shape,
val poster: Shape,
val avatar: Shape,
val playerPanel: Shape,
)
@Immutable
data class NuvioSpacingTokens(
val screenHorizontal: Dp,
val screenTop: Dp,
val screenBottom: Dp,
val sectionGap: Dp,
val listGap: Dp,
val railGap: Dp,
val cardPadding: Dp,
val cardPaddingCompact: Dp,
val controlGap: Dp,
val dialogPadding: Dp,
val sheetPadding: Dp,
val playerOverlayPadding: Dp,
)
@Immutable
data class NuvioBorderTokens(
val hairline: Dp,
val thin: Dp,
val medium: Dp,
)
@Immutable
data class NuvioElevationTokens(
val flat: Dp,
val raised: Dp,
val modal: Dp,
val overlay: Dp,
val playerControls: Dp,
)
@Immutable
data class NuvioMotionTokens(
val instantMillis: Int,
val fastMillis: Int,
val normalMillis: Int,
val sheetEnterMillis: Int,
val sheetExitMillis: Int,
val slowMillis: Int,
val cinematicMillis: Int,
val standard: Easing,
val emphasized: Easing,
val decelerate: Easing,
val accelerate: Easing,
)
@Immutable
data class NuvioOpacityTokens(
val invisible: Float,
val disabled: Float,
val secondary: Float,
val muted: Float,
val selected: Float,
val hover: Float,
val pressed: Float,
val subtle: Float,
val medium: Float,
val strong: Float,
val overlayLight: Float,
val overlayMedium: Float,
val overlayHeavy: Float,
val visible: Float,
)
@Immutable
data class NuvioIconTokens(
val xs: Dp,
val sm: Dp,
val md: Dp,
val lg: Dp,
val xl: Dp,
val xxl: Dp,
)
@Immutable
data class NuvioBreakpointTokens(
val phone: Dp,
val largePhone: Dp,
val tablet: Dp,
val largeTablet: Dp,
val desktop: Dp,
val playerWide: Dp,
)
@Immutable
data class NuvioComponentTokens(
val navItemShape: Shape,
val navIconSize: Dp,
val navItemMaxWidth: Dp,
val sheetMaxWidth: Dp,
val dialogMaxWidth: Dp,
val chipHorizontalPadding: Dp,
val chipVerticalPadding: Dp,
val posterRadius: Dp,
val avatarSize: Dp,
val playerPanelMaxWidth: Dp,
)
@Immutable
data class NuvioThemeTokens(
val colors: NuvioColorTokens,
val spacing: NuvioSpacingTokens,
val shapes: NuvioShapeTokens,
val borders: NuvioBorderTokens,
val elevation: NuvioElevationTokens,
val motion: NuvioMotionTokens,
val opacity: NuvioOpacityTokens,
val icons: NuvioIconTokens,
val breakpoints: NuvioBreakpointTokens,
val components: NuvioComponentTokens,
)
internal val LocalNuvioThemeTokens = staticCompositionLocalOf {
defaultNuvioThemeTokens(ThemeColors.White, amoled = false, colorScheme = null)
}
val MaterialTheme.nuvio: NuvioThemeTokens
@Composable
@Stable
get() = LocalNuvioThemeTokens.current
internal fun defaultNuvioThemeTokens(
palette: ThemeColorPalette,
amoled: Boolean,
colorScheme: ColorScheme?,
): NuvioThemeTokens {
val background = if (amoled) Color.Black else palette.background
val textPrimary = Color(0xFFF5F7F8)
val textSecondary = Color(0xFFB8BEC5)
val textMuted = Color(0xFF969CA3)
val surface = palette.backgroundElevated
val surfaceCard = palette.backgroundCard
val accent = palette.secondary
val accentStrong = palette.secondaryVariant
val borderSubtle = Color(0xFF252A2A).copy(alpha = 0.55f)
val borderDefault = Color(0xFF252A2A)
val overlayScrim = Color.Black.copy(alpha = NuvioTokens.Opacity.overlayMedium)
return NuvioThemeTokens(
colors = NuvioColorTokens(
background = background,
backgroundAmoled = Color.Black,
surface = surface,
surfaceElevated = surface,
surfaceCard = surfaceCard,
surfaceSheet = surface,
surfaceDialog = surface,
surfacePopover = surfaceCard,
nativeChrome = background,
textPrimary = textPrimary,
textSecondary = textSecondary,
textMuted = textMuted,
textDisabled = textMuted.copy(alpha = NuvioTokens.Opacity.disabled),
textInverse = Color(0xFF111111),
accent = accent,
accentStrong = accentStrong,
onAccent = palette.onSecondary,
focusRing = palette.focusRing,
focusBackground = palette.focusBackground,
borderSubtle = borderSubtle,
borderDefault = borderDefault,
borderStrong = Color(0xFF3A4040),
borderFocus = palette.focusRing,
borderSelected = accent.copy(alpha = NuvioTokens.Opacity.strong),
success = Color(0xFF66BB6A),
warning = Color(0xFFFFC857),
danger = colorScheme?.error ?: Color(0xFFE36A8A),
info = Color(0xFF42A5F5),
neutral = textMuted,
overlayScrim = overlayScrim,
overlayHover = Color.White.copy(alpha = NuvioTokens.Opacity.hover),
overlayPressed = Color.White.copy(alpha = NuvioTokens.Opacity.pressed),
overlaySelected = Color.White.copy(alpha = NuvioTokens.Opacity.selected),
overlayDisabled = Color.Black.copy(alpha = NuvioTokens.Opacity.disabled),
shimmer = Color.White.copy(alpha = 0.10f),
skeleton = Color.White.copy(alpha = 0.06f),
playerControlsBackground = Color.Black.copy(alpha = 0.72f),
playerControlsForeground = Color.White,
playerTimelineTrack = Color.White.copy(alpha = 0.30f),
playerTimelineFill = accent,
playerSubtitlePreview = Color.Black.copy(alpha = 0.55f),
playerBuffering = accent,
parentalGuide = Color(0xFF5D1F1F),
),
spacing = NuvioSpacingTokens(
screenHorizontal = NuvioTokens.Space.s16,
screenTop = NuvioTokens.Space.s10,
screenBottom = NuvioTokens.Space.s18,
sectionGap = NuvioTokens.Space.s24,
listGap = NuvioTokens.Space.s12,
railGap = NuvioTokens.Space.s14,
cardPadding = NuvioTokens.Space.s18,
cardPaddingCompact = NuvioTokens.Space.s14,
controlGap = NuvioTokens.Space.s8,
dialogPadding = NuvioTokens.Space.s20,
sheetPadding = NuvioTokens.Space.s20,
playerOverlayPadding = NuvioTokens.Space.s24,
),
shapes = NuvioShapeTokens(
card = RoundedCornerShape(NuvioTokens.Radius.card),
compactCard = RoundedCornerShape(NuvioTokens.Radius.compactCard),
sheet = RoundedCornerShape(NuvioTokens.Radius.sheet),
dialog = RoundedCornerShape(NuvioTokens.Radius.dialog),
button = RoundedCornerShape(NuvioTokens.Radius.button),
chip = RoundedCornerShape(NuvioTokens.Radius.chip),
poster = RoundedCornerShape(NuvioTokens.Radius.poster),
avatar = RoundedCornerShape(NuvioTokens.Radius.avatar),
playerPanel = RoundedCornerShape(NuvioTokens.Radius.playerPanel),
),
borders = NuvioBorderTokens(
hairline = NuvioTokens.Border.hairline,
thin = NuvioTokens.Border.thin,
medium = NuvioTokens.Border.medium,
),
elevation = NuvioElevationTokens(
flat = NuvioTokens.Elevation.none,
raised = NuvioTokens.Elevation.raised,
modal = NuvioTokens.Elevation.modal,
overlay = NuvioTokens.Elevation.overlay,
playerControls = NuvioTokens.Elevation.playerControls,
),
motion = NuvioMotionTokens(
instantMillis = NuvioTokens.Motion.instantMillis,
fastMillis = NuvioTokens.Motion.fastMillis,
normalMillis = NuvioTokens.Motion.normalMillis,
sheetEnterMillis = NuvioTokens.Motion.sheetEnterMillis,
sheetExitMillis = NuvioTokens.Motion.sheetExitMillis,
slowMillis = NuvioTokens.Motion.slowMillis,
cinematicMillis = NuvioTokens.Motion.cinematicMillis,
standard = NuvioTokens.Motion.standard,
emphasized = NuvioTokens.Motion.emphasized,
decelerate = NuvioTokens.Motion.decelerate,
accelerate = NuvioTokens.Motion.accelerate,
),
opacity = NuvioOpacityTokens(
invisible = NuvioTokens.Opacity.invisible,
disabled = NuvioTokens.Opacity.disabled,
secondary = NuvioTokens.Opacity.secondary,
muted = NuvioTokens.Opacity.muted,
selected = NuvioTokens.Opacity.selected,
hover = NuvioTokens.Opacity.hover,
pressed = NuvioTokens.Opacity.pressed,
subtle = NuvioTokens.Opacity.subtle,
medium = NuvioTokens.Opacity.medium,
strong = NuvioTokens.Opacity.strong,
overlayLight = NuvioTokens.Opacity.overlayLight,
overlayMedium = NuvioTokens.Opacity.overlayMedium,
overlayHeavy = NuvioTokens.Opacity.overlayHeavy,
visible = NuvioTokens.Opacity.visible,
),
icons = NuvioIconTokens(
xs = NuvioTokens.Icon.xs,
sm = NuvioTokens.Icon.sm,
md = NuvioTokens.Icon.md,
lg = NuvioTokens.Icon.lg,
xl = NuvioTokens.Icon.xl,
xxl = NuvioTokens.Icon.xxl,
),
breakpoints = NuvioBreakpointTokens(
phone = NuvioTokens.Breakpoint.phone,
largePhone = NuvioTokens.Breakpoint.largePhone,
tablet = NuvioTokens.Breakpoint.tablet,
largeTablet = NuvioTokens.Breakpoint.largeTablet,
desktop = NuvioTokens.Breakpoint.desktop,
playerWide = NuvioTokens.Breakpoint.playerWide,
),
components = NuvioComponentTokens(
navItemShape = RoundedCornerShape(NuvioTokens.Radius.xl),
navIconSize = NuvioTokens.Icon.xl,
navItemMaxWidth = 150.dp,
sheetMaxWidth = 520.dp,
dialogMaxWidth = 460.dp,
chipHorizontalPadding = NuvioTokens.Space.s14,
chipVerticalPadding = NuvioTokens.Space.s8,
posterRadius = NuvioTokens.Radius.poster,
avatarSize = NuvioTokens.Space.s48,
playerPanelMaxWidth = 600.dp,
),
)
}

View file

@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
data class NuvioTypeScale(
val labelXs: TextStyle,
@ -22,16 +21,16 @@ data class NuvioTypeScale(
internal val LocalNuvioTypeScale = staticCompositionLocalOf {
NuvioTypeScale(
labelXs = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.Medium),
labelSm = TextStyle(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium),
bodySm = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal),
bodyMd = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
bodyLg = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium),
titleSm = TextStyle(fontSize = 18.sp, lineHeight = 22.sp, fontWeight = FontWeight.Bold),
titleMd = TextStyle(fontSize = 22.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold),
titleLg = TextStyle(fontSize = 28.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold),
displaySm = TextStyle(fontSize = 32.sp, lineHeight = 36.sp, fontWeight = FontWeight.ExtraBold),
displayMd = TextStyle(fontSize = 48.sp, lineHeight = 52.sp, fontWeight = FontWeight.ExtraBold),
labelXs = TextStyle(fontSize = NuvioTokens.Type.labelXs, lineHeight = NuvioTokens.LineHeight.labelXs, fontWeight = FontWeight.Medium),
labelSm = TextStyle(fontSize = NuvioTokens.Type.labelSm, lineHeight = NuvioTokens.LineHeight.labelSm, fontWeight = FontWeight.Medium),
bodySm = TextStyle(fontSize = NuvioTokens.Type.bodySm, lineHeight = NuvioTokens.LineHeight.bodySm, fontWeight = FontWeight.Normal),
bodyMd = TextStyle(fontSize = NuvioTokens.Type.bodyMd, lineHeight = NuvioTokens.LineHeight.bodyMd, fontWeight = FontWeight.Normal),
bodyLg = TextStyle(fontSize = NuvioTokens.Type.bodyLg, lineHeight = NuvioTokens.LineHeight.bodyLg, fontWeight = FontWeight.Medium),
titleSm = TextStyle(fontSize = NuvioTokens.Type.titleSm, lineHeight = NuvioTokens.LineHeight.titleSm, fontWeight = FontWeight.Bold),
titleMd = TextStyle(fontSize = NuvioTokens.Type.titleMd, lineHeight = NuvioTokens.LineHeight.titleMd, fontWeight = FontWeight.Bold),
titleLg = TextStyle(fontSize = NuvioTokens.Type.titleLg, lineHeight = NuvioTokens.LineHeight.titleLg, fontWeight = FontWeight.Bold),
displaySm = TextStyle(fontSize = NuvioTokens.Type.displaySm, lineHeight = NuvioTokens.LineHeight.displaySm, fontWeight = FontWeight.ExtraBold),
displayMd = TextStyle(fontSize = NuvioTokens.Type.displayMd, lineHeight = NuvioTokens.LineHeight.displayMd, fontWeight = FontWeight.ExtraBold),
)
}

View file

@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
data class ThemeColorPalette(
val secondary: Color,
val secondaryVariant: Color,
val nativeAccentHex: String,
val onSecondary: Color = Color.White,
val onSecondaryVariant: Color = Color.White,
val focusRing: Color,
@ -19,6 +20,7 @@ object ThemeColors {
val Crimson = ThemeColorPalette(
secondary = Color(0xFFE53935),
secondaryVariant = Color(0xFFC62828),
nativeAccentHex = "#E53935",
focusRing = Color(0xFFFF5252),
focusBackground = Color(0xFF3D1A1A),
background = Color(0xFF0D0D0D),
@ -29,6 +31,7 @@ object ThemeColors {
val Ocean = ThemeColorPalette(
secondary = Color(0xFF1E88E5),
secondaryVariant = Color(0xFF1565C0),
nativeAccentHex = "#1E88E5",
focusRing = Color(0xFF42A5F5),
focusBackground = Color(0xFF1A2D3D),
background = Color(0xFF0D0D0F),
@ -39,6 +42,7 @@ object ThemeColors {
val Violet = ThemeColorPalette(
secondary = Color(0xFF8E24AA),
secondaryVariant = Color(0xFF6A1B9A),
nativeAccentHex = "#8E24AA",
focusRing = Color(0xFFAB47BC),
focusBackground = Color(0xFF2D1A3D),
background = Color(0xFF0D0D0F),
@ -49,6 +53,7 @@ object ThemeColors {
val Emerald = ThemeColorPalette(
secondary = Color(0xFF43A047),
secondaryVariant = Color(0xFF2E7D32),
nativeAccentHex = "#43A047",
focusRing = Color(0xFF66BB6A),
focusBackground = Color(0xFF1A3D1E),
background = Color(0xFF0D0D0D),
@ -59,6 +64,7 @@ object ThemeColors {
val Amber = ThemeColorPalette(
secondary = Color(0xFFFB8C00),
secondaryVariant = Color(0xFFEF6C00),
nativeAccentHex = "#FB8C00",
focusRing = Color(0xFFFFA726),
focusBackground = Color(0xFF3D2D1A),
background = Color(0xFF0F0D0D),
@ -69,6 +75,7 @@ object ThemeColors {
val Rose = ThemeColorPalette(
secondary = Color(0xFFD81B60),
secondaryVariant = Color(0xFFC2185B),
nativeAccentHex = "#D81B60",
focusRing = Color(0xFFEC407A),
focusBackground = Color(0xFF3D1A2D),
background = Color(0xFF0D0D0D),
@ -79,6 +86,7 @@ object ThemeColors {
val White = ThemeColorPalette(
secondary = Color(0xFFF5F5F5),
secondaryVariant = Color(0xFFE0E0E0),
nativeAccentHex = "#F5F5F5",
onSecondary = Color(0xFF111111),
onSecondaryVariant = Color(0xFF111111),
focusRing = Color(0xFFFFFFFF),

View file

@ -26,7 +26,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.trakt.TraktListTab
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
@ -49,35 +48,36 @@ fun TraktListPickerDialog(
onDismiss: () -> Unit,
) {
if (!visible) return
val tokens = MaterialTheme.nuvio
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(20.dp),
color = tokens.colors.surfaceDialog,
shape = RoundedCornerShape(NuvioTokens.Radius.xl),
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(tokens.spacing.cardPadding),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
Text(
text = stringResource(Res.string.compose_trakt_list_picker_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
)
if (!errorMessage.isNullOrBlank()) {
Text(
text = errorMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
color = tokens.colors.danger,
)
}
@ -85,21 +85,21 @@ fun TraktListPickerDialog(
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp),
.height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(24.dp),
strokeWidth = tokens.borders.medium,
modifier = Modifier.size(tokens.icons.lg),
)
Text(
text = stringResource(Res.string.compose_trakt_list_picker_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = tokens.colors.textMuted,
)
}
}
@ -107,8 +107,8 @@ fun TraktListPickerDialog(
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
items(items = tabs, key = { it.key }) { tab ->
val selected = membership[tab.key] == true
@ -117,27 +117,27 @@ fun TraktListPickerDialog(
.fillMaxWidth()
.background(
color = if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
tokens.colors.accent.copy(alpha = tokens.opacity.selected)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
tokens.colors.surfaceCard.copy(alpha = tokens.opacity.medium)
},
shape = RoundedCornerShape(12.dp),
shape = tokens.shapes.compactCard,
)
.clickable(enabled = !isPending) { onToggle(tab.key) }
.padding(horizontal = 14.dp, vertical = 12.dp),
.padding(horizontal = NuvioTokens.Space.s14, vertical = tokens.spacing.listGap),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = tab.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
color = tokens.colors.textPrimary,
)
if (selected) {
androidx.compose.material3.Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
tint = tokens.colors.accent,
)
}
}
@ -147,14 +147,14 @@ fun TraktListPickerDialog(
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s10, Alignment.End),
) {
Button(
onClick = onDismiss,
enabled = !isPending,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurface,
containerColor = tokens.colors.surfaceCard,
contentColor = tokens.colors.textPrimary,
),
) {
Text(stringResource(Res.string.action_cancel))
@ -165,9 +165,9 @@ fun TraktListPickerDialog(
) {
if (isPending) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp),
color = tokens.colors.onAccent,
strokeWidth = tokens.borders.medium,
modifier = Modifier.size(tokens.icons.sm),
)
} else {
Text(stringResource(Res.string.action_save))

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
const val CATALOG_PAGE_SIZE = 100
private const val DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT = 3
data class CatalogPage(
val items: List<MetaPreview>,
@ -22,6 +23,11 @@ data class CatalogPage(
val nextSkip: Int?,
)
data class CatalogPaginationState(
val nextSkip: Int?,
val consecutiveDuplicatePages: Int = 0,
)
private val inflightMutex = Mutex()
private val inflightRequestScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val inflightRequests = mutableMapOf<String, CompletableDeferred<String>>()
@ -84,7 +90,40 @@ suspend fun fetchCatalogPage(
}
fun AddonCatalog.supportsPagination(): Boolean =
extra.any { property -> property.name == "skip" }
extra.any { property -> property.name.equals("skip", ignoreCase = true) }
fun nextCatalogPaginationState(
supportsPagination: Boolean,
requestedSkip: Int,
page: CatalogPage,
loadedNewItems: Boolean,
consecutiveDuplicatePages: Int,
): CatalogPaginationState {
if (!supportsPagination || page.rawItemCount <= 0 || page.nextSkip == null) {
return CatalogPaginationState(nextSkip = null)
}
if (loadedNewItems) {
return CatalogPaginationState(nextSkip = page.nextSkip)
}
val duplicatePages = consecutiveDuplicatePages + 1
val advancedSkip = if (page.nextSkip > requestedSkip) {
page.nextSkip
} else {
requestedSkip + page.rawItemCount.coerceAtLeast(1)
}
return if (duplicatePages < DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT && advancedSkip > requestedSkip) {
CatalogPaginationState(
nextSkip = advancedSkip,
consecutiveDuplicatePages = duplicatePages,
)
} else {
CatalogPaginationState(
nextSkip = null,
consecutiveDuplicatePages = duplicatePages,
)
}
}
fun mergeCatalogItems(
existing: List<MetaPreview>,

View file

@ -6,6 +6,7 @@ data class CatalogUiState(
val items: List<MetaPreview> = emptyList(),
val isLoading: Boolean = false,
val nextSkip: Int? = null,
val consecutiveDuplicatePages: Int = 0,
val errorMessage: String? = null,
) {
val canLoadMore: Boolean

View file

@ -1,9 +1,13 @@
package com.nuvio.app.features.catalog
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
import com.nuvio.app.features.collection.catalogRouteKey
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toMetaPreview
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -16,8 +20,6 @@ import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
object CatalogRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(CatalogUiState())
@ -28,25 +30,15 @@ object CatalogRepository {
private val scrollPositions = linkedMapOf<CatalogRequest, CatalogScrollPosition>()
fun load(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
force: Boolean = false,
) {
val request = catalogRequest(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
)
val request = catalogRequest(target)
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
return
}
activeRequest = request
if (manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) {
if (target is CatalogTarget.Library) {
fetchInternalLibrary(request)
return
}
@ -68,25 +60,17 @@ object CatalogRepository {
}
fun scrollPosition(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
): CatalogScrollPosition =
scrollPositions[catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)]
scrollPositions[catalogRequest(target)]
?: CatalogScrollPosition()
fun saveScrollPosition(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
target: CatalogTarget,
firstVisibleItemIndex: Int,
firstVisibleItemScrollOffset: Int,
) {
val request = catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)
val request = catalogRequest(target)
scrollPositions[request] = CatalogScrollPosition(
firstVisibleItemIndex = firstVisibleItemIndex,
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
@ -102,9 +86,10 @@ object CatalogRepository {
activeJob = scope.launch {
runCatching {
val target = request.target as CatalogTarget.Library
LibraryRepository.ensureLoaded()
LibraryRepository.uiState.value.sections
.firstOrNull { it.type == request.catalogId }
.firstOrNull { it.type == target.sectionType }
?.items
.orEmpty()
.map { it.toMetaPreview() }
@ -149,13 +134,22 @@ object CatalogRepository {
activeJob = scope.launch {
runCatching {
fetchCatalogPage(
manifestUrl = request.manifestUrl,
type = request.type,
catalogId = request.catalogId,
genre = request.genre,
skip = requestedSkip.takeIf { it > 0 },
).withUnreleasedFilter(request.hideUnreleasedContent)
when (val target = request.target) {
is CatalogTarget.Addon -> fetchCatalogPage(
manifestUrl = target.manifestUrl,
type = target.contentType,
catalogId = target.catalogId,
genre = target.genre,
skip = requestedSkip.takeIf { it > 0 },
)
is CatalogTarget.CollectionSource -> fetchCollectionSourcePage(
target = target,
page = requestedSkip.takeIf { it > 0 } ?: 1,
)
is CatalogTarget.Library -> error(getString(Res.string.catalog_load_failed))
}.withUnreleasedFilter(request.hideUnreleasedContent)
}.fold(
onSuccess = { page ->
if (activeRequest != request) return@fold
@ -165,12 +159,20 @@ object CatalogRepository {
} else {
mergeCatalogItems(_uiState.value.items, page.items)
}
val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val supportsPagination = request.target.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > current.items.size
val paginationState = nextCatalogPaginationState(
supportsPagination = supportsPagination,
requestedSkip = requestedSkip,
page = page,
loadedNewItems = loadedNewItems,
consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages,
)
_uiState.value = CatalogUiState(
items = mergedItems,
isLoading = false,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
nextSkip = paginationState.nextSkip,
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
errorMessage = null,
)
},
@ -188,19 +190,9 @@ object CatalogRepository {
}
}
private fun catalogRequest(
manifestUrl: String,
type: String,
catalogId: String,
genre: String? = null,
supportsPagination: Boolean = false,
): CatalogRequest =
private fun catalogRequest(target: CatalogTarget): CatalogRequest =
CatalogRequest(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent,
)
}
@ -211,11 +203,26 @@ private fun CatalogPage.withUnreleasedFilter(hideUnreleasedContent: Boolean): Ca
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
}
private suspend fun fetchCollectionSourcePage(
target: CatalogTarget.CollectionSource,
page: Int,
): CatalogPage {
CollectionRepository.initialize()
val source = CollectionRepository.getCollection(target.collectionId)
?.folders
?.firstOrNull { it.id == target.folderId }
?.resolvedSources
?.firstOrNull { it.catalogRouteKey() == target.sourceKey }
?: error(getString(Res.string.catalog_load_failed))
return when {
source.isTmdb -> TmdbCollectionSourceResolver.resolve(source = source, page = page)
source.isTrakt -> TraktPublicListSourceResolver.resolve(source = source, page = page)
else -> error(getString(Res.string.catalog_load_failed))
}
}
private data class CatalogRequest(
val manifestUrl: String,
val type: String,
val catalogId: String,
val genre: String?,
val supportsPagination: Boolean,
val target: CatalogTarget,
val hideUnreleasedContent: Boolean,
)

View file

@ -68,11 +68,7 @@ import org.jetbrains.compose.resources.stringResource
fun CatalogScreen(
title: String,
subtitle: String,
manifestUrl: String,
type: String,
catalogId: String,
supportsPagination: Boolean,
genre: String? = null,
target: CatalogTarget,
onBack: () -> Unit,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -87,19 +83,11 @@ fun CatalogScreen(
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val initialScrollPosition = remember(
manifestUrl,
type,
catalogId,
genre,
supportsPagination,
target,
homeCatalogSettingsUiState.hideUnreleasedContent,
) {
CatalogRepository.scrollPosition(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
)
}
val gridState = rememberLazyGridState(
@ -109,26 +97,18 @@ fun CatalogScreen(
var headerHeightPx by remember { mutableIntStateOf(0) }
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
LaunchedEffect(target, homeCatalogSettingsUiState.hideUnreleasedContent) {
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
)
}
LaunchedEffect(gridState, manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
LaunchedEffect(gridState, target, homeCatalogSettingsUiState.hideUnreleasedContent) {
snapshotFlow { gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
CatalogRepository.saveScrollPosition(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
firstVisibleItemIndex = index,
firstVisibleItemScrollOffset = offset,
)
@ -148,7 +128,7 @@ fun CatalogScreen(
}
}
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
LaunchedEffect(networkStatusUiState.condition, target) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
NetworkCondition.ServersUnreachable,
@ -160,11 +140,7 @@ fun CatalogScreen(
if (!observedOfflineState) return@LaunchedEffect
observedOfflineState = false
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
force = true,
)
}
@ -208,11 +184,7 @@ fun CatalogScreen(
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
CatalogRepository.load(
manifestUrl = manifestUrl,
type = type,
catalogId = catalogId,
genre = genre,
supportsPagination = supportsPagination,
target = target,
force = true,
)
},

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.catalog
import kotlinx.serialization.Serializable
sealed interface CatalogTarget {
val contentType: String
val supportsPagination: Boolean
data class Addon(
val manifestUrl: String,
override val contentType: String,
val catalogId: String,
val genre: String? = null,
override val supportsPagination: Boolean = false,
) : CatalogTarget
data class Library(
override val contentType: String,
val sectionType: String,
) : CatalogTarget {
override val supportsPagination: Boolean = false
}
data class CollectionSource(
val collectionId: String,
val folderId: String,
val sourceKey: String,
override val contentType: String,
override val supportsPagination: Boolean = false,
) : CatalogTarget
}
@Serializable
enum class CatalogTargetKind {
ADDON,
LIBRARY,
COLLECTION_SOURCE,
}

View file

@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
)
private fun collectionSourceKey(source: CollectionSource): String =
when {
source.isTmdb -> {
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
}
source.isTrakt -> {
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
}
else -> {
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
}
}
source.catalogRouteKey()
private fun selectedMediaTypes(
state: CollectionEditorUiState,

View file

@ -44,7 +44,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -1918,7 +1918,7 @@ private fun GenrePickerSheet(
onSelect: (String?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
NuvioModalBottomSheet(
onDismissRequest = onDismiss,

View file

@ -68,6 +68,21 @@ data class CollectionSource(
}
}
internal fun CollectionSource.catalogRouteKey(): String =
when {
isTmdb -> {
"tmdb_${tmdbSourceType}_${tmdbId}_${mediaType}_${sortBy}_${filters.hashCode()}"
}
isTrakt -> {
"trakt_${traktListId}_${mediaType}_${TraktListSort.normalize(sortBy)}_${TraktSortHow.normalize(sortHow)}"
}
else -> {
"addon_${addonId}_${type}_${catalogId}_${genre.orEmpty()}"
}
}
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
isTrakt && (traktListId == null || traktListId <= 0L)

View file

@ -4,8 +4,10 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.catalog.mergeCatalogItems
import com.nuvio.app.features.catalog.nextCatalogPaginationState
import com.nuvio.app.features.catalog.supportsPagination
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
@ -35,6 +37,7 @@ data class FolderTab(
val label: String,
val typeLabel: String = "",
val source: CollectionSource? = null,
val sourceKey: String? = null,
val manifestUrl: String? = null,
val type: String = "",
val catalogId: String = "",
@ -44,6 +47,7 @@ data class FolderTab(
val isLoading: Boolean = true,
val isLoadingMore: Boolean = false,
val nextSkip: Int? = null,
val consecutiveDuplicatePages: Int = 0,
val error: String? = null,
val isAllTab: Boolean = false,
) {
@ -136,7 +140,7 @@ object FolderDetailRepository {
),
)
}
sources.forEach { source ->
sources.forEachIndexed { sourceIndex, source ->
if (source.isTmdb) {
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
@ -145,6 +149,7 @@ object FolderDetailRepository {
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
typeLabel = "TMDB",
source = source,
sourceKey = source.catalogRouteKey(),
type = type,
catalogId = tmdbCatalogId(source),
supportsPagination = source.tmdbSourceType !in setOf(
@ -172,6 +177,7 @@ object FolderDetailRepository {
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
typeLabel = typeLabel,
source = source,
sourceKey = source.catalogRouteKey(),
type = type,
catalogId = traktCatalogId(source),
supportsPagination = true,
@ -179,7 +185,7 @@ object FolderDetailRepository {
),
)
} else {
val catalogSource = source.addonCatalogSource() ?: return@forEach
val catalogSource = source.addonCatalogSource() ?: return@forEachIndexed
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
val addon = resolvedCatalog?.addon
val catalog = resolvedCatalog?.catalog
@ -191,6 +197,7 @@ object FolderDetailRepository {
label = "$label ($typeLabel)$genreSuffix",
typeLabel = typeLabel,
source = source,
sourceKey = source.catalogRouteKey(),
manifestUrl = addon?.manifestUrl,
type = catalogSource.type,
catalogId = catalogSource.catalogId,
@ -298,6 +305,7 @@ object FolderDetailRepository {
isLoading = true,
isLoadingMore = false,
nextSkip = null,
consecutiveDuplicatePages = 0,
error = null,
)
} else {
@ -340,12 +348,20 @@ object FolderDetailRepository {
}
val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
val loadedNewItems = reset || mergedItems.size > tab.items.size
val paginationState = nextCatalogPaginationState(
supportsPagination = supportsPagination,
requestedSkip = requestedSkip,
page = page,
loadedNewItems = loadedNewItems,
consecutiveDuplicatePages = if (reset) 0 else tab.consecutiveDuplicatePages,
)
tab.copy(
items = mergedItems,
supportsPagination = supportsPagination,
isLoading = false,
isLoadingMore = false,
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
nextSkip = paginationState.nextSkip,
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
error = null,
)
}
@ -408,19 +424,38 @@ object FolderDetailRepository {
fun getCatalogSectionsForRows(): List<HomeCatalogSection> {
val current = _uiState.value
val folder = current.folder ?: return emptyList()
val collectionId = activeCollectionId ?: return emptyList()
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab ->
return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.mapNotNull { tab ->
val directSource = tab.source?.let { it.isTmdb || it.isTrakt } == true
val target = if (directSource) {
val sourceKey = tab.sourceKey ?: return@mapNotNull null
CatalogTarget.CollectionSource(
collectionId = collectionId,
folderId = folder.id,
sourceKey = sourceKey,
contentType = tab.type,
supportsPagination = tab.supportsPagination,
)
} else {
val manifestUrl = tab.manifestUrl ?: return@mapNotNull null
CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = tab.type,
catalogId = tab.catalogId,
genre = tab.genre,
supportsPagination = tab.supportsPagination,
)
}
HomeCatalogSection(
key = "folder_${folder.id}_${tab.label}",
title = tab.label,
subtitle = tab.typeLabel,
addonName = "",
type = tab.type,
manifestUrl = tab.manifestUrl.orEmpty(),
catalogId = tab.catalogId,
target = target,
items = tab.items,
availableItemCount = tab.items.size,
supportsPagination = tab.supportsPagination,
hasMore = tab.canLoadMore,
)
}
}

View file

@ -5,7 +5,7 @@ import com.nuvio.app.features.streams.StreamItem
internal object DebridMagnetBuilder {
fun fromStream(stream: StreamItem): String? {
stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it }
val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null
val hash = stream.p2pInfoHash ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
internal interface DebridProviderApi {
val provider: DebridProvider
@ -35,6 +36,7 @@ internal object DebridProviderApis {
}
}
@Serializable
internal data class DebridDeviceAuthorization(
val providerId: String,
val deviceCode: String,

View file

@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage {
fun saveStreamNameTemplate(template: String)
fun loadStreamDescriptionTemplate(): String?
fun saveStreamDescriptionTemplate(template: String)
fun loadPendingDeviceAuthorization(providerId: String): String?
fun savePendingDeviceAuthorization(providerId: String, payload: String)
fun clearPendingDeviceAuthorization(providerId: String)
fun exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject)
}

View file

@ -31,6 +31,7 @@ data class MetaDetails(
val website: String? = null,
val hasScheduledVideos: Boolean = false,
val moreLikeThis: List<MetaPreview> = emptyList(),
val moreLikeThisSource: MoreLikeThisSource? = null,
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val trailers: List<MetaTrailer> = emptyList(),
@ -38,6 +39,11 @@ data class MetaDetails(
val videos: List<MetaVideo> = emptyList(),
)
enum class MoreLikeThisSource {
TMDB,
TRAKT,
}
data class MetaExternalRating(
val source: String,
val value: Double,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamProxyHeaders
import com.nuvio.app.features.streams.normalizeStreamType
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@ -288,6 +289,7 @@ internal object MetaDetailsParser {
externalUrl = externalUrl,
addonName = addonName,
addonId = "embedded",
streamType = normalizeStreamType(obj.string("type")),
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,

View file

@ -13,6 +13,11 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.tmdb.TmdbMetadataService
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktRelatedRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -58,7 +63,7 @@ object MetaDetailsRepository {
}
val cachedBaseMeta = cachedEntry.baseMeta
if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
if (!shouldEnrichForMetaScreen(cachedBaseMeta, id, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter())
activeRequestKey = requestKey
return
@ -81,6 +86,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = cachedBaseMeta,
fallbackItemId = id,
fallbackItemType = type,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
@ -116,6 +122,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@ -139,6 +146,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = result,
fallbackItemId = metaLookupId,
fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@ -152,6 +160,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = tmdbMeta,
fallbackItemId = id,
fallbackItemType = type,
mdbListSettings = mdbListSettings,
metaScreenSettingsFingerprint = metaScreenSettingsFingerprint,
)
@ -300,13 +309,14 @@ object MetaDetailsRepository {
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
fallbackItemType: String,
mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings,
metaScreenSettingsFingerprint: String,
) {
val cachedEntry = CachedMetaEntry(baseMeta = meta)
cachedMetaByRequestKey[requestKey] = cachedEntry
if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) {
if (!shouldEnrichForMetaScreen(meta, fallbackItemId, mdbListSettings)) {
_uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter())
activeRequestKey = requestKey
return
@ -321,6 +331,7 @@ object MetaDetailsRepository {
requestKey = requestKey,
meta = meta,
fallbackItemId = fallbackItemId,
fallbackItemType = fallbackItemType,
settings = mdbListSettings,
settingsFingerprint = metaScreenSettingsFingerprint,
)
@ -337,16 +348,22 @@ object MetaDetailsRepository {
requestKey: String,
meta: MetaDetails,
fallbackItemId: String,
fallbackItemType: String,
settings: com.nuvio.app.features.mdblist.MdbListSettings,
settingsFingerprint: String,
): MetaDetails {
val enrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) {
val mdbListEnrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) {
MdbListMetadataService.enrichMeta(
meta = meta,
fallbackItemId = fallbackItemId,
settings = settings,
)
} ?: meta
val enrichedMeta = applyMoreLikeThisSource(
meta = mdbListEnrichedMeta,
fallbackItemId = fallbackItemId,
fallbackItemType = fallbackItemType,
)
cachedMetaByRequestKey[requestKey] = cachedMetaByRequestKey[requestKey]
?.copy(
@ -362,6 +379,49 @@ object MetaDetailsRepository {
return enrichedMeta
}
private suspend fun applyMoreLikeThisSource(
meta: MetaDetails,
fallbackItemId: String,
fallbackItemType: String,
): MetaDetails {
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val traktSettings = TraktSettingsRepository.uiState.value
val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
val shouldUseTrakt = shouldUseTraktMoreLikeThis(
isAuthenticated = isTraktAuthenticated,
source = traktSettings.moreLikeThisSource,
) && supportsMoreLikeThis(meta, fallbackItemType)
if (shouldUseTrakt) {
val items = runCatching {
TraktRelatedRepository.getRelated(
meta = meta,
fallbackItemId = fallbackItemId,
fallbackItemType = fallbackItemType,
)
}.onFailure { error ->
log.w { "Failed to load Trakt related titles for ${meta.id}: ${error.message}" }
}.getOrDefault(emptyList())
return meta.copy(
moreLikeThis = items,
moreLikeThisSource = MoreLikeThisSource.TRAKT.takeIf { items.isNotEmpty() },
)
}
val tmdbSettings = TmdbSettingsRepository.snapshot()
if (!tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis) {
return meta.copy(moreLikeThis = emptyList(), moreLikeThisSource = null)
}
return meta.copy(
moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { meta.moreLikeThis.isNotEmpty() },
)
}
private fun shouldFetchMdbListOnMetaScreen(
meta: MetaDetails,
fallbackItemId: String,
@ -372,18 +432,63 @@ object MetaDetailsRepository {
settings = settings,
)
private fun shouldEnrichForMetaScreen(
meta: MetaDetails,
fallbackItemId: String,
settings: com.nuvio.app.features.mdblist.MdbListSettings,
): Boolean {
if (shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, settings)) return true
return shouldApplyMoreLikeThisSource(meta)
}
private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean {
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val traktSettings = TraktSettingsRepository.uiState.value
val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED
val tmdbSettings = TmdbSettingsRepository.snapshot()
return shouldUseTraktMoreLikeThis(
isAuthenticated = isTraktAuthenticated,
source = traktSettings.moreLikeThisSource,
) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty()
}
private fun buildMetaScreenSettingsFingerprint(
settings: com.nuvio.app.features.mdblist.MdbListSettings,
): String {
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
val providers = settings.enabledProvidersInPriorityOrder().joinToString(",")
return "${settings.enabled}:${settings.apiKey.trim()}:$providers"
val traktSettings = TraktSettingsRepository.uiState.value
val traktAuthMode = TraktAuthRepository.uiState.value.mode
val tmdbSettings = TmdbSettingsRepository.snapshot()
return buildString {
append("${settings.enabled}:${settings.apiKey.trim()}:$providers")
append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode")
append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}")
}
}
private fun supportsMoreLikeThis(meta: MetaDetails, fallbackItemType: String): Boolean =
normalizeMoreLikeThisType(meta.type) != null || normalizeMoreLikeThisType(fallbackItemType) != null
private fun normalizeMoreLikeThisType(value: String?): String? =
when (value?.trim()?.lowercase()) {
"movie", "film" -> "movie"
"series", "show", "tv", "tvshow" -> "series"
else -> null
}
private fun MetaDetails.withUnreleasedFilter(): MetaDetails {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val releasedMoreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate)
return copy(
moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate),
moreLikeThis = releasedMoreLikeThis,
moreLikeThisSource = moreLikeThisSource.takeIf { releasedMoreLikeThis.isNotEmpty() },
collectionItems = collectionItems.filterReleasedItems(todayIsoDate),
)
}

View file

@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
@ -87,8 +88,8 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktCommentReview
@ -96,6 +97,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository
import com.nuvio.app.features.trakt.TraktCommentsSettings
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
@ -109,6 +111,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@ -140,6 +143,14 @@ fun MetaDetailsScreen(
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
}.collectAsStateWithLifecycle()
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val tmdbSettingsUiState by remember {
TmdbSettingsRepository.ensureLoaded()
TmdbSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val libraryUiState by remember {
LibraryRepository.ensureLoaded()
LibraryRepository.uiState
@ -179,20 +190,30 @@ fun MetaDetailsScreen(
var pickerPending by remember(type, id) { mutableStateOf(false) }
var pickerError by remember(type, id) { mutableStateOf<String?>(null) }
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, Double>>(emptyMap()) }
var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) }
val shouldShowComments = commentsEnabled &&
traktAuthUiState.mode == TraktConnectionMode.CONNECTED &&
displayedMeta != null &&
displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" }
LaunchedEffect(displayedMeta?.id, shouldShowComments) {
if (!shouldShowComments || displayedMeta == null) {
LaunchedEffect(displayedMeta?.id) {
deferredMetaWorkAllowed = false
if (displayedMeta != null) {
delay(250)
deferredMetaWorkAllowed = true
}
}
LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) {
if (displayedMeta == null || !shouldShowComments) {
comments = emptyList()
commentsCurrentPage = 0
commentsPageCount = 0
commentsError = null
return@LaunchedEffect
}
if (!deferredMetaWorkAllowed) return@LaunchedEffect
isCommentsLoading = true
commentsError = null
try {
@ -206,8 +227,9 @@ fun MetaDetailsScreen(
isCommentsLoading = false
}
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) {
LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) {
val metaForRatings = displayedMeta
if (!deferredMetaWorkAllowed) return@LaunchedEffect
if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) {
episodeImdbRatings = emptyMap()
return@LaunchedEffect
@ -237,6 +259,22 @@ fun MetaDetailsScreen(
}
}
LaunchedEffect(
type,
id,
displayedMeta?.id,
uiState.isLoading,
traktSettingsUiState.moreLikeThisSource,
traktAuthUiState.mode,
tmdbSettingsUiState.enabled,
tmdbSettingsUiState.useMoreLikeThis,
tmdbSettingsUiState.language,
) {
if (displayedMeta != null && !uiState.isLoading) {
MetaDetailsRepository.load(type, id)
}
}
LaunchedEffect(networkStatusUiState.condition, displayedMeta, uiState.isLoading, type, id) {
when (networkStatusUiState.condition) {
NetworkCondition.NoInternet,
@ -421,29 +459,6 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) {
if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) {
DetailDebridWarmupTarget(
videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id,
season = seriesAction?.seasonNumber,
episode = seriesAction?.episodeNumber,
)
} else {
DetailDebridWarmupTarget(
videoId = meta.id,
season = null,
episode = null,
)
}
}
LaunchedEffect(meta.type, debridWarmupTarget) {
AddonStreamWarmupRepository.preload(
type = meta.type,
videoId = debridWarmupTarget.videoId,
season = debridWarmupTarget.season,
episode = debridWarmupTarget.episode,
)
}
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@ -483,11 +498,16 @@ fun MetaDetailsScreen(
var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) }
var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) }
val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle()
LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) {
LaunchedEffect(
heroTrailerPlaybackEnabled,
heroTrailerCandidate?.id,
heroTrailerCandidate?.key,
deferredMetaWorkAllowed,
) {
heroTrailerPlaybackSource = null
heroTrailerReady = false
heroTrailerFinished = false
if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) {
if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) {
return@LaunchedEffect
}
val resolvedSource = runCatching {
@ -692,7 +712,7 @@ fun MetaDetailsScreen(
savedProgress?.lastPositionMs,
)
}
val scrollState = rememberScrollState()
val listState = rememberLazyListState()
val density = LocalDensity.current
val safeAreaTopPx = with(density) {
WindowInsets.statusBars
@ -702,7 +722,20 @@ fun MetaDetailsScreen(
}
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else {
heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset
}
val heroScrollOffset = detailScrollOffsetPx.toInt()
val headerTarget = if (
heroHeightPx > 0 &&
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)
) {
1f
} else {
0f
}
val heroTrailerSourceUrl = heroTrailerPlaybackSource
?.videoUrl
?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails }
@ -711,7 +744,7 @@ fun MetaDetailsScreen(
?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() }
val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null &&
!isLeavingDetails &&
(heroHeightPx == 0 || scrollState.value <= thresholdPx)
(heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx)
val headerProgress by animateFloatAsState(
targetValue = headerTarget,
animationSpec = tween(
@ -725,7 +758,7 @@ fun MetaDetailsScreen(
val isTablet = maxWidth >= 720.dp
val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp
val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet)
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground
val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed
Box(modifier = Modifier.fillMaxSize()) {
if (cinematicEnabled) {
@ -746,123 +779,120 @@ fun MetaDetailsScreen(
)
}
}
Column(
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.zIndex(1f)
.verticalScroll(scrollState),
.zIndex(1f),
) {
DetailHero(
meta = meta,
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
scrollOffset = scrollState.value,
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
heroTrailerReady = heroTrailerReady,
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
heroTrailerMuted = heroTrailerMuted,
onHeroTrailerMuteToggle = {
HeroTrailerAudioState.toggleMuted()
},
onHeroTrailerReady = {
if (!heroTrailerFinished) {
heroTrailerReady = true
}
},
onHeroTrailerEnded = {
heroTrailerReady = false
heroTrailerFinished = true
},
onHeroTrailerError = {
heroTrailerReady = false
heroTrailerFinished = true
},
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = contentHorizontalPadding)
.widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ConfiguredMetaSections(
settings = metaScreenSettingsUiState,
item(key = "detail-hero") {
DetailHero(
meta = meta,
isTablet = isTablet,
playButtonLabel = playButtonLabel,
isSaved = isSaved,
isWatched = isWatched,
onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
onWatchedClick = toggleWatched,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber,
hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes,
hasAdditionalInfoSection = hasAdditionalInfoSection,
hasCollectionSection = hasCollectionSection,
hasMoreLikeThisSection = hasMoreLikeThisSection,
shouldShowComments = shouldShowComments,
comments = comments,
isCommentsLoading = isCommentsLoading,
isCommentsLoadingMore = isCommentsLoadingMore,
commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount,
commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = {
detailsScope.launch {
isCommentsLoading = true
commentsError = null
try {
val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true)
comments = result.items
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (e: Exception) {
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
}
isCommentsLoading = false
contentMaxWidth = contentMaxWidth,
scrollOffset = heroScrollOffset,
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
heroTrailerReady = heroTrailerReady,
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
heroTrailerMuted = heroTrailerMuted,
onHeroTrailerMuteToggle = {
HeroTrailerAudioState.toggleMuted()
},
onHeroTrailerReady = {
if (!heroTrailerFinished) {
heroTrailerReady = true
}
},
onLoadMoreComments = {
detailsScope.launch {
isCommentsLoadingMore = true
try {
val nextPage = commentsCurrentPage + 1
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
val existingIds = comments.map { it.id }.toSet()
val newComments = result.items.filter { it.id !in existingIds }
comments = comments + newComments
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (_: Exception) { }
isCommentsLoadingMore = false
}
onHeroTrailerEnded = {
heroTrailerReady = false
heroTrailerFinished = true
},
onHeroTrailerError = {
heroTrailerReady = false
heroTrailerFinished = true
},
onCommentClick = { review -> selectedComment = review },
onTrailerClick = resolveTrailer,
progressByVideoId = progressByVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
configuredMetaSectionItems(
settings = metaScreenSettingsUiState,
meta = meta,
isTablet = isTablet,
contentHorizontalPadding = contentHorizontalPadding,
contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified,
playButtonLabel = playButtonLabel,
isSaved = isSaved,
isWatched = isWatched,
onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = toggleSaved,
onSaveLongClick = openLibraryListPicker,
onWatchedClick = toggleWatched,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
preferredEpisodeNumber = seriesAction?.episodeNumber,
hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes,
hasAdditionalInfoSection = hasAdditionalInfoSection,
hasCollectionSection = hasCollectionSection,
hasMoreLikeThisSection = hasMoreLikeThisSection,
shouldShowComments = shouldShowComments,
comments = comments,
isCommentsLoading = isCommentsLoading,
isCommentsLoadingMore = isCommentsLoadingMore,
commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount,
commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = {
detailsScope.launch {
isCommentsLoading = true
commentsError = null
try {
val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true)
comments = result.items
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (e: Exception) {
commentsError = e.message ?: getString(Res.string.details_comments_load_failed)
}
isCommentsLoading = false
}
},
onLoadMoreComments = {
detailsScope.launch {
isCommentsLoadingMore = true
try {
val nextPage = commentsCurrentPage + 1
val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage)
val existingIds = comments.map { it.id }.toSet()
val newComments = result.items.filter { it.id !in existingIds }
comments = comments + newComments
commentsCurrentPage = result.currentPage
commentsPageCount = result.pageCount
} catch (_: Exception) { }
isCommentsLoadingMore = false
}
},
onCommentClick = { review -> selectedComment = review },
onTrailerClick = resolveTrailer,
progressByVideoId = progressByVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
item(key = "detail-bottom-spacer") {
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp)))
}
}
@ -875,7 +905,7 @@ fun MetaDetailsScreen(
.fillMaxWidth()
.height(132.dp)
.graphicsLayer {
translationY = heroHeightPx.toFloat() - scrollState.value
translationY = heroHeightPx.toFloat() - detailScrollOffsetPx
}
.background(
Brush.verticalGradient(
@ -1237,6 +1267,229 @@ private fun MetaDetails.toMetaPreview(): MetaPreview =
genres = genres,
)
private fun LazyListScope.configuredMetaSectionItems(
settings: MetaScreenSettingsUiState,
meta: MetaDetails,
isTablet: Boolean,
contentHorizontalPadding: Dp,
contentMaxWidth: Dp,
playButtonLabel: String,
isSaved: Boolean,
isWatched: Boolean,
onPrimaryPlayClick: () -> Unit,
onPrimaryPlayLongClick: (() -> Unit)?,
onSaveClick: () -> Unit,
onSaveLongClick: (() -> Unit)?,
onWatchedClick: () -> Unit,
showManualPlayOption: Boolean,
preferredEpisodeSeasonNumber: Int?,
preferredEpisodeNumber: Int?,
hasProductionSection: Boolean,
hasTrailersSection: Boolean,
hasEpisodes: Boolean,
hasAdditionalInfoSection: Boolean,
hasCollectionSection: Boolean,
hasMoreLikeThisSection: Boolean,
shouldShowComments: Boolean,
comments: List<TraktCommentReview>,
isCommentsLoading: Boolean,
isCommentsLoadingMore: Boolean,
commentsCurrentPage: Int,
commentsPageCount: Int,
commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onSeasonLongPress: (Int) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson, String?) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?,
) {
val enabledItems = settings.items.filter { it.enabled }
fun sectionHasContent(key: MetaScreenSectionKey): Boolean =
metaSectionHasContent(
key = key,
meta = meta,
hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes,
hasAdditionalInfoSection = hasAdditionalInfoSection,
hasCollectionSection = hasCollectionSection,
hasMoreLikeThisSection = hasMoreLikeThisSection,
shouldShowComments = shouldShowComments,
comments = comments,
isCommentsLoading = isCommentsLoading,
commentsError = commentsError,
)
fun addSectionItem(
key: String,
sectionItems: List<MetaScreenSectionItem>,
forceTabLayout: Boolean = settings.tabLayout,
) {
item(key = key) {
DetailSectionContainer(
horizontalPadding = contentHorizontalPadding,
contentMaxWidth = contentMaxWidth,
) {
ConfiguredMetaSections(
settings = settings.copy(
items = sectionItems,
tabLayout = forceTabLayout,
),
meta = meta,
isTablet = isTablet,
playButtonLabel = playButtonLabel,
isSaved = isSaved,
isWatched = isWatched,
onPrimaryPlayClick = onPrimaryPlayClick,
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
onSaveClick = onSaveClick,
onSaveLongClick = onSaveLongClick,
onWatchedClick = onWatchedClick,
showManualPlayOption = showManualPlayOption,
preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber,
preferredEpisodeNumber = preferredEpisodeNumber,
hasProductionSection = hasProductionSection,
hasTrailersSection = hasTrailersSection,
hasEpisodes = hasEpisodes,
hasAdditionalInfoSection = hasAdditionalInfoSection,
hasCollectionSection = hasCollectionSection,
hasMoreLikeThisSection = hasMoreLikeThisSection,
shouldShowComments = shouldShowComments,
comments = comments,
isCommentsLoading = isCommentsLoading,
isCommentsLoadingMore = isCommentsLoadingMore,
commentsCurrentPage = commentsCurrentPage,
commentsPageCount = commentsPageCount,
commentsError = commentsError,
episodeImdbRatings = episodeImdbRatings,
onRetryComments = onRetryComments,
onLoadMoreComments = onLoadMoreComments,
onCommentClick = onCommentClick,
onTrailerClick = onTrailerClick,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
onSeasonLongPress = onSeasonLongPress,
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
}
if (!settings.tabLayout) {
enabledItems
.filter { sectionHasContent(it.key) }
.forEach { section ->
addSectionItem(
key = "detail-section-${section.key.name}",
sectionItems = listOf(section),
forceTabLayout = false,
)
}
return
}
val processedGroups = mutableSetOf<Int>()
enabledItems.forEach { section ->
val groupId = section.tabGroup
if (groupId == null) {
if (sectionHasContent(section.key)) {
addSectionItem(
key = "detail-section-${section.key.name}",
sectionItems = listOf(section),
forceTabLayout = true,
)
}
} else if (groupId !in processedGroups) {
processedGroups.add(groupId)
val groupMembers = enabledItems.filter { item ->
item.tabGroup == groupId && sectionHasContent(item.key)
}
if (groupMembers.isNotEmpty()) {
addSectionItem(
key = "detail-section-group-$groupId",
sectionItems = groupMembers,
forceTabLayout = groupMembers.size > 1,
)
}
}
}
}
@Composable
private fun DetailSectionContainer(
horizontalPadding: Dp,
contentMaxWidth: Dp,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding)
.padding(bottom = 20.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (contentMaxWidth == Dp.Unspecified) {
Modifier
} else {
Modifier.widthIn(max = contentMaxWidth)
},
),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
private fun metaSectionHasContent(
key: MetaScreenSectionKey,
meta: MetaDetails,
hasProductionSection: Boolean,
hasTrailersSection: Boolean,
hasEpisodes: Boolean,
hasAdditionalInfoSection: Boolean,
hasCollectionSection: Boolean,
hasMoreLikeThisSection: Boolean,
shouldShowComments: Boolean,
comments: List<TraktCommentReview>,
isCommentsLoading: Boolean,
commentsError: String?,
): Boolean =
when (key) {
MetaScreenSectionKey.ACTIONS -> true
MetaScreenSectionKey.OVERVIEW -> true
MetaScreenSectionKey.PRODUCTION -> hasProductionSection
MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty()
MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())
MetaScreenSectionKey.TRAILERS -> hasTrailersSection
MetaScreenSectionKey.EPISODES -> hasEpisodes
MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection
MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection
MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection
}
@Composable
@OptIn(ExperimentalSharedTransitionApi::class)
private fun ConfiguredMetaSections(
@ -1417,11 +1670,17 @@ private fun ConfiguredMetaSections(
}
MetaScreenSectionKey.MORE_LIKE_THIS -> {
if (hasMoreLikeThisSection) {
val sourceLabel = when (meta.moreLikeThisSource) {
MoreLikeThisSource.TMDB -> stringResource(Res.string.detail_more_like_this_powered_by_tmdb)
MoreLikeThisSource.TRAKT -> stringResource(Res.string.detail_more_like_this_powered_by_trakt)
null -> null
}
DetailPosterRailSection(
title = stringResource(Res.string.details_more_like_this),
items = meta.moreLikeThis,
watchedKeys = watchedKeys,
showHeader = showHeader,
sourceLabel = sourceLabel,
onPosterClick = onOpenMeta,
)
}
@ -1535,14 +1794,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
private data class DetailDebridWarmupTarget(
val videoId: String,
val season: Int?,
val episode: Int?,
)
private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean =
hasEpisodes || type.equals("series", ignoreCase = true) ||
type.equals("show", ignoreCase = true) ||
type.equals("tv", ignoreCase = true)

View file

@ -24,7 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@ -51,7 +51,7 @@ fun CommentDetailSheet(
onNext: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scrollState = rememberScrollState()
LaunchedEffect(comment.id) {

View file

@ -42,6 +42,7 @@ import com.nuvio.app.core.ui.AppIconResource
import com.nuvio.app.core.ui.appIconPainter
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_play
import nuvio.composeapp.generated.resources.details_actions_menu_label
import org.jetbrains.compose.resources.stringResource
data class DetailSecondaryAction(
@ -58,7 +59,7 @@ fun DetailActionButtons(
modifier: Modifier = Modifier,
playLabel: String = stringResource(Res.string.action_play),
secondaryActions: List<DetailSecondaryAction> = emptyList(),
actionsMenuLabel: String = "More actions",
actionsMenuLabel: String = stringResource(Res.string.details_actions_menu_label),
isTablet: Boolean = false,
onPlayClick: () -> Unit = {},
onPlayLongClick: (() -> Unit)? = null,

View file

@ -111,7 +111,7 @@ fun DetailFloatingHeader(
.padding(horizontal = 10.dp),
contentAlignment = Alignment.Center,
) {
if (meta.logo != null && !logoLoadError) {
if (!meta.logo.isNullOrBlank() && !logoLoadError) {
AsyncImage(
model = meta.logo,
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),

View file

@ -32,7 +32,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@ -79,6 +81,10 @@ fun DetailHero(
val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() +
8.dp +
((40.dp - muteIconSize) / 2)
var logoLoadError by remember(meta.id, meta.logo) {
mutableStateOf(false)
}
val logoUrl = meta.logo?.takeIf { it.isNotBlank() }
Box(
modifier = Modifier
@ -198,9 +204,9 @@ fun DetailHero(
.padding(bottom = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (meta.logo != null) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = meta.logo,
model = logoUrl,
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
modifier = Modifier
.fillMaxWidth(if (isTablet) 0.56f else 0.6f)
@ -208,6 +214,7 @@ fun DetailHero(
.height(if (isTablet) 72.dp else 80.dp),
alignment = Alignment.Center,
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
} else {
Text(

View file

@ -1,8 +1,15 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.core.ui.NuvioShelfSection
@ -19,28 +26,45 @@ fun DetailPosterRailSection(
modifier: Modifier = Modifier,
showHeader: Boolean = true,
headerHorizontalPadding: Dp = 0.dp,
sourceLabel: String? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
if (items.isEmpty()) return
NuvioShelfSection(
title = if (showHeader) title else "",
entries = items,
modifier = modifier,
headerHorizontalPadding = headerHorizontalPadding,
rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
showHeaderAccent = false,
key = { item -> item.stableKey() },
) { item ->
HomePosterCard(
item = item,
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
Column(modifier = modifier.fillMaxWidth()) {
NuvioShelfSection(
title = if (showHeader) title else "",
entries = items,
headerHorizontalPadding = headerHorizontalPadding,
rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
showHeaderAccent = false,
key = { item -> item.stableKey() },
) { item ->
HomePosterCard(
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
}
sourceLabel
?.takeIf { it.isNotBlank() }
?.let { label ->
Text(
text = label,
modifier = Modifier
.align(Alignment.End)
.padding(end = headerHorizontalPadding, top = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -12,7 +12,7 @@ import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@ -46,7 +46,7 @@ fun EpisodeWatchedActionSheet(
showPlayManually: Boolean = false,
onPlayManually: (() -> Unit)? = null,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
@ -140,7 +140,7 @@ fun SeasonWatchedActionSheet(
onToggleSeasonWatched: () -> Unit,
onMarkPreviousSeasonsWatched: () -> Unit,
) {
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(

View file

@ -18,7 +18,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import com.nuvio.app.core.ui.rememberNuvioBottomSheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -67,7 +67,7 @@ fun TrailerPlayerPopup(
}
}.joinToString(separator = "")
val sheetState = rememberNuvioBottomSheetState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
var playerError by remember(playbackSource?.videoUrl, playbackSource?.audioUrl) {
mutableStateOf<String?>(null)

View file

@ -3,7 +3,8 @@ package com.nuvio.app.features.home
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.sync.MOBILE_SYNC_PLATFORM
import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS
import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
@Serializable
@ -53,6 +55,30 @@ private data class SupabaseHomeCatalogSettingsBlob(
@SerialName("updated_at") val updatedAt: String? = null,
)
private data class RemoteHomeCatalogSettings(
val platform: String,
val payload: SyncHomeCatalogPayload,
val updatedAt: String?,
val hasHideUnreleasedContent: Boolean,
val hasHideCatalogUnderline: Boolean,
)
private data class PullToken(
val userId: String,
val profileId: Int,
)
private data class ObservedHomeCatalogChange(
val signature: String,
val token: PullToken?,
val initialPullCompleteAtEmission: Boolean,
)
private data class HomeCatalogChangeSignature(
val signature: String,
val token: PullToken,
)
object HomeCatalogSettingsSyncService {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("HomeCatalogSettingsSyncService")
@ -62,6 +88,8 @@ object HomeCatalogSettingsSyncService {
}
private const val PUSH_DEBOUNCE_MS = 1500L
private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content"
private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline"
@Volatile
var isSyncingFromRemote: Boolean = false
@ -69,6 +97,12 @@ object HomeCatalogSettingsSyncService {
private var pushJob: Job? = null
private var observeJob: Job? = null
@Volatile
private var completedInitialPull: PullToken? = null
@Volatile
private var remoteAppliedSignature: HomeCatalogChangeSignature? = null
fun startObserving() {
if (observeJob?.isActive == true) return
observeLocalChangesAndPush()
@ -76,48 +110,28 @@ object HomeCatalogSettingsSyncService {
suspend fun pullFromServer(profileId: Int) {
runCatching {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", MOBILE_SYNC_PLATFORM)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params)
val blobs = result.decodeList<SupabaseHomeCatalogSettingsBlob>()
val blob = blobs.firstOrNull()
val pullToken = currentPullToken(profileId) ?: return
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
val remote = fetchBestRemotePayload(profileId, localPayload)
if (blob == null) {
log.i { "pullFromServer — no remote home catalog settings found" }
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
if (localPayload.items.isNotEmpty()) {
pushToRemote(profileId)
}
if (remote == null) {
log.i { "pullFromServer — no remote home catalog settings found; preserving local" }
markInitialPullComplete(pullToken)
return
}
val remotePayload = runCatching {
json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson)
}.getOrNull()
if (remotePayload == null) {
log.w { "pullFromServer — failed to parse remote home catalog settings" }
return
}
val remotePayload = remote.payload
if (remotePayload.items.isEmpty()) {
log.i { "pullFromServer — remote has empty items, preserving local catalog order" }
isSyncingFromRemote = true
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
isSyncingFromRemote = false
val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload()
if (localPayload.items.isNotEmpty()) {
pushToRemote(profileId)
}
applyRemotePayload(remotePayload, pullToken)
markInitialPullComplete(pullToken)
return
}
isSyncingFromRemote = true
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
isSyncingFromRemote = false
applyRemotePayload(remotePayload, pullToken)
log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" }
markInitialPullComplete(pullToken)
}.onFailure { e ->
isSyncingFromRemote = false
log.e(e) { "pullFromServer — FAILED" }
@ -125,28 +139,28 @@ object HomeCatalogSettingsSyncService {
}
fun triggerPush() {
val requestedToken = currentPullToken()
if (requestedToken == null || !hasCompletedInitialPull(requestedToken)) {
log.d { "triggerPush — skipped before initial home catalog pull completed" }
return
}
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
if (isSyncingFromRemote) return@launch
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
pushToRemote()
if (currentPullToken() != requestedToken) return@launch
pushToRemote(requestedToken.profileId)
}
}
private suspend fun pushToRemote() {
pushToRemote(ProfileRepository.activeProfileId)
}
private suspend fun pushToRemote(profileId: Int) {
runCatching {
val payload = HomeCatalogSettingsRepository.exportToSyncPayload()
val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload)
val jsonElement = mergedSharedPayloadJson(profileId, payload)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", MOBILE_SYNC_PLATFORM)
put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM)
put("p_settings_json", jsonElement)
}
SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params)
@ -160,16 +174,178 @@ object HomeCatalogSettingsSyncService {
private fun observeLocalChangesAndPush() {
observeJob = scope.launch {
HomeCatalogSettingsRepository.uiState
.map { it.signature }
.map { state ->
val token = currentPullToken()
ObservedHomeCatalogChange(
signature = state.signature,
token = token,
initialPullCompleteAtEmission = token?.let(::hasCompletedInitialPull) == true,
)
}
.drop(1)
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS)
.collect {
.collect { change ->
val token = change.token ?: return@collect
val changeSignature = HomeCatalogChangeSignature(change.signature, token)
if (!change.initialPullCompleteAtEmission) {
if (changeSignature == remoteAppliedSignature) {
remoteAppliedSignature = null
}
log.d { "observeLocalChangesAndPush — skipped before initial home catalog pull completed" }
return@collect
}
if (changeSignature == remoteAppliedSignature) {
remoteAppliedSignature = null
log.d { "observeLocalChangesAndPush — skipped remote-applied catalog change" }
return@collect
}
if (isSyncingFromRemote) return@collect
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
pushToRemote()
if (currentPullToken() != token) return@collect
pushToRemote(token.profileId)
}
}
}
private fun currentPullToken(profileId: Int = ProfileRepository.activeProfileId): PullToken? {
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return null
return PullToken(
userId = authState.userId,
profileId = profileId,
)
}
private fun hasCompletedInitialPull(token: PullToken): Boolean =
completedInitialPull == token
private fun markInitialPullComplete(token: PullToken) {
completedInitialPull = token
}
private fun applyRemotePayload(
payload: SyncHomeCatalogPayload,
token: PullToken,
) {
isSyncingFromRemote = true
try {
HomeCatalogSettingsRepository.applyFromRemote(payload)
remoteAppliedSignature = HomeCatalogChangeSignature(
signature = HomeCatalogSettingsRepository.uiState.value.signature,
token = token,
)
} finally {
isSyncingFromRemote = false
}
}
private suspend fun fetchBestRemotePayload(
profileId: Int,
localPayload: SyncHomeCatalogPayload,
): RemoteHomeCatalogSettings? {
val shared = fetchRemotePayload(
profileId = profileId,
platform = HOME_CATALOG_SHARED_SYNC_PLATFORM,
localPayload = localPayload,
)
val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS
.mapNotNull { platform ->
fetchRemotePayload(
profileId = profileId,
platform = platform,
localPayload = localPayload,
)
}
val rows = listOfNotNull(shared) + legacyRows
val selected = rows
.filter { it.payload.items.isNotEmpty() }
.maxByOrNull { it.updatedAt.orEmpty() }
?: shared
?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() }
return selected?.withNewestStandaloneSettings(rows)
}
private suspend fun fetchRemotePayload(
profileId: Int,
platform: String,
localPayload: SyncHomeCatalogPayload,
): RemoteHomeCatalogSettings? {
val blob = fetchRemoteBlob(profileId, platform) ?: return null
val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload)
if (payload == null) {
log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" }
return null
}
return RemoteHomeCatalogSettings(
platform = platform,
payload = payload,
updatedAt = blob.updatedAt,
hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY),
hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY),
)
}
private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings(
rows: List<RemoteHomeCatalogSettings>,
): RemoteHomeCatalogSettings {
val hideUnreleasedSource = rows
.filter { it.hasHideUnreleasedContent }
.maxByOrNull { it.updatedAt.orEmpty() }
val hideUnderlineSource = rows
.filter { it.hasHideCatalogUnderline }
.maxByOrNull { it.updatedAt.orEmpty() }
return copy(
payload = payload.copy(
hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent
?: payload.hideUnreleasedContent,
hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline
?: payload.hideCatalogUnderline,
),
)
}
private suspend fun fetchRemoteBlob(
profileId: Int,
platform: String,
): SupabaseHomeCatalogSettingsBlob? {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_platform", platform)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params)
return result.decodeList<SupabaseHomeCatalogSettingsBlob>().firstOrNull()
}
private fun decodePayloadPreservingLocalDefaults(
settingsJson: JsonObject,
localPayload: SyncHomeCatalogPayload,
): SyncHomeCatalogPayload? = runCatching {
val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson)
decoded.copy(
hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) {
decoded.hideUnreleasedContent
} else {
localPayload.hideUnreleasedContent
},
hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) {
decoded.hideCatalogUnderline
} else {
localPayload.hideCatalogUnderline
},
)
}.getOrNull()
private suspend fun mergedSharedPayloadJson(
profileId: Int,
payload: SyncHomeCatalogPayload,
): JsonObject {
val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject
val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson
return buildJsonObject {
remoteJson?.forEach { (key, value) -> put(key, value) }
localJson.forEach { (key, value) -> put(key, value) }
}
}
}

View file

@ -1,7 +1,7 @@
package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
import com.nuvio.app.features.catalog.CatalogTarget
data class MetaPreview(
val id: String,
@ -33,16 +33,14 @@ data class HomeCatalogSection(
val title: String,
val subtitle: String,
val addonName: String,
val type: String,
val manifestUrl: String,
val catalogId: String,
val target: CatalogTarget,
val items: List<MetaPreview>,
val availableItemCount: Int = items.size,
val supportsPagination: Boolean = false,
val hasMore: Boolean = false,
)
fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean =
availableItemCount > previewLimit || (supportsPagination && availableItemCount >= CATALOG_PAGE_SIZE)
availableItemCount > previewLimit || hasMore
data class HomeUiState(
val isLoading: Boolean = false,

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.CatalogTarget
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.collection.CollectionSource
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
import com.nuvio.app.features.collection.catalogRouteKey
import com.nuvio.app.features.collection.findCollectionCatalog
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
import com.nuvio.app.features.watchprogress.CurrentDateProvider
@ -238,12 +240,15 @@ object HomeRepository {
title = defaultTitle,
subtitle = addonName,
addonName = addonName,
type = type,
manifestUrl = manifestUrl,
catalogId = catalogId,
target = CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = type,
catalogId = catalogId,
supportsPagination = supportsPagination,
),
items = emptyList(),
availableItemCount = 0,
supportsPagination = supportsPagination,
hasMore = false,
)
}
@ -252,12 +257,15 @@ object HomeRepository {
title = defaultTitle,
subtitle = addonName,
addonName = addonName,
type = type,
manifestUrl = manifestUrl,
catalogId = catalogId,
target = CatalogTarget.Addon(
manifestUrl = manifestUrl,
contentType = type,
catalogId = catalogId,
supportsPagination = supportsPagination,
),
items = items,
availableItemCount = page.rawItemCount,
supportsPagination = supportsPagination,
hasMore = supportsPagination && page.nextSkip != null,
)
}
@ -411,19 +419,7 @@ object HomeRepository {
}
private fun collectionSourceKey(source: CollectionSource): String =
listOf(
source.provider,
source.addonId,
source.type,
source.catalogId,
source.genre,
source.tmdbSourceType,
source.tmdbId?.toString(),
source.traktListId?.toString(),
source.mediaType,
source.sortBy,
source.sortHow,
).joinToString(":") { it.orEmpty() }
source.catalogRouteKey()
}
private const val HOME_HERO_ITEM_LIMIT = 8

View file

@ -281,11 +281,20 @@ fun HomeScreen(
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle()
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf<Set<String>>(emptySet()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
LaunchedEffect(activeProfileId, cwCacheClearVersion) {
if (cwCacheClearVersion == 0) return@LaunchedEffect
nextUpItemsBySeries = emptyMap()
processedNextUpContentIds = emptySet()
}
val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) {
ContinueWatchingEnrichmentCache.getSnapshots()
}
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,

View file

@ -30,8 +30,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -347,13 +349,18 @@ private fun HeroContentBlock(
layout: HomeHeroLayout,
onItemClick: ((MetaPreview) -> Unit)?,
) {
var logoLoadError by remember(item.type, item.id, item.logo) {
mutableStateOf(false)
}
val logoUrl = item.logo?.takeIf { it.isNotBlank() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally,
) {
if (item.logo != null) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = item.logo,
model = logoUrl,
contentDescription = item.name,
modifier = Modifier
.fillMaxWidth(layout.logoWidthFraction)
@ -363,6 +370,7 @@ private fun HeroContentBlock(
},
alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
} else {
Text(
@ -589,9 +597,10 @@ private fun resolveHeroTargetPage(
abs(velocityX) > HERO_SWIPE_VELOCITY_THRESHOLD
if (!thresholdPassed) return startPage
val currentPage = startPage.coerceIn(0, itemCount - 1)
return when {
totalDx > 0f -> (startPage - 1).coerceAtLeast(0)
totalDx < 0f -> (startPage + 1).coerceAtMost(itemCount - 1)
else -> startPage
totalDx > 0f -> if (currentPage == 0) itemCount - 1 else currentPage - 1
totalDx < 0f -> if (currentPage == itemCount - 1) 0 else currentPage + 1
else -> currentPage
}
}

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.library
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.home.PosterShape
@ -16,11 +17,6 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.library_local_tab_title
import nuvio.composeapp.generated.resources.library_other
import org.jetbrains.compose.resources.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -33,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
@ -41,6 +38,12 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.library_local_tab_title
import nuvio.composeapp.generated.resources.library_other
import nuvio.composeapp.generated.resources.trakt_lists_update_failed
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
@Serializable
private data class StoredLibraryPayload(
@ -217,7 +220,13 @@ object LibraryRepository {
if (isTraktLibrarySourceActive()) {
syncScope.launch {
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
.onFailure { e ->
log.e(e) { "Failed to toggle Trakt watchlist" }
NuvioToastController.show(
e.message?.takeIf { it.isNotBlank() }
?: getString(Res.string.trakt_lists_update_failed),
)
}
publish()
}
return
@ -476,11 +485,16 @@ object LibraryRepository {
}
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library"
private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other"
internal fun localLibraryListTab(): TraktListTab =
TraktListTab(
key = LOCAL_LIBRARY_LIST_KEY,
title = runBlocking { getString(Res.string.library_local_tab_title) },
title = localizedStringOrDefault(
resource = Res.string.library_local_tab_title,
fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE,
),
type = TraktListType.WATCHLIST,
)
@ -552,7 +566,7 @@ private fun PosterShape.toSyncName(): String =
internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim()
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
if (normalized.isBlank()) return localizedLibraryOtherTitle()
return normalized
.split('-', '_', ' ')
@ -560,5 +574,15 @@ internal fun String.toLibraryDisplayTitle(): String {
.joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() }
}
.ifBlank { runBlocking { getString(Res.string.library_other) } }
.ifBlank { localizedLibraryOtherTitle() }
}
private fun localizedLibraryOtherTitle(): String =
localizedStringOrDefault(
resource = Res.string.library_other,
fallback = DEFAULT_LIBRARY_OTHER_TITLE,
)
private fun localizedStringOrDefault(resource: StringResource, fallback: String): String =
runCatching { runBlocking { getString(resource) } }
.getOrDefault(fallback)

View file

@ -65,7 +65,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
import com.nuvio.app.core.ui.nuvioConsumePointerEvents
import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryItemType
@ -177,30 +177,35 @@ fun LibraryScreen(
listState = listState,
) {
stickyHeader {
androidx.compose.foundation.layout.Column(
modifier = Modifier
.fillMaxWidth()
.nuvioBlockPointerPassthrough()
.background(MaterialTheme.colorScheme.background),
) {
NuvioScreenHeader(
title = if (sourceMode == LibraryViewMode.Cloud) {
stringResource(Res.string.library_title)
} else if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.background)
.nuvioConsumePointerEvents(),
)
LibrarySourceSwitch(
selectedMode = sourceMode,
onModeSelected = { mode ->
sourceModeName = mode.name
},
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(6.dp))
androidx.compose.foundation.layout.Column(
modifier = Modifier.fillMaxWidth(),
) {
NuvioScreenHeader(
title = if (sourceMode == LibraryViewMode.Cloud) {
stringResource(Res.string.library_title)
} else if (isTraktSource) {
stringResource(Res.string.library_trakt_title)
} else {
stringResource(Res.string.library_title)
},
modifier = Modifier.padding(horizontal = 16.dp),
)
LibrarySourceSwitch(
selectedMode = sourceMode,
onModeSelected = { mode ->
sourceModeName = mode.name
},
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(6.dp))
}
}
}

View file

@ -2,8 +2,8 @@ package com.nuvio.app.features.player
/**
* Orchestrates the full external player launch flow:
* fetches subtitles if forwarding is enabled, then returns an enriched request
* for the caller to dispatch.
* fetches subtitles if forwarding is enabled, downloads them to local cache,
* then returns an enriched request for the caller to dispatch.
*/
suspend fun prepareExternalPlayerLaunch(
request: ExternalPlayerPlaybackRequest,
@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch(
)
if (subtitles != null) {
onOverlayMessage("Downloading subtitles...")
val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles)
if (cachedSubtitles != null) {
return request.copy(subtitles = cachedSubtitles)
}
// Fallback: use original URLs if caching fails
return request.copy(subtitles = subtitles)
}
}

View file

@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest(
val sourceHeaders: Map<String, String> = emptyMap(),
val resumePositionMs: Long = 0L,
val subtitles: List<SubtitleInput>? = null,
)
val season: Int? = null,
val episode: Int? = null,
val episodeTitle: String? = null,
) {
/**
* Builds a display title for external players.
* For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title"
* For movies: just the content name (title).
*/
fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String {
if (season == null || episode == null) return title
val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) {
"$title - $seasonEp - $episodeTitle"
} else {
"$title - $seasonEp"
}
}
}
enum class ExternalPlayerOpenResult {
Opened,

View file

@ -58,6 +58,7 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
streamType: String? = null,
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,
playWhenReady: Boolean = true,

View file

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -30,10 +29,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@ -52,15 +49,16 @@ import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamCard
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.isSelectableForPlayback
@ -97,12 +95,12 @@ fun PlayerEpisodesPanel(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)),
exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)),
) {
Box(
modifier = modifier
@ -112,22 +110,24 @@ fun PlayerEpisodesPanel(
interactionSource = remember { MutableInteractionSource() },
onClick = onDismiss,
)
.background(colorScheme.scrim.copy(alpha = 0.52f)),
.background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)),
exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)),
enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } +
fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)),
exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } +
fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)),
) {
Box(
modifier = Modifier
.widthIn(max = 520.dp)
.widthIn(max = tokens.components.playerPanelMaxWidth)
.fillMaxWidth(0.92f)
.heightIn(max = 620.dp)
.clip(RoundedCornerShape(24.dp))
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp))
.heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s64)
.clip(tokens.shapes.playerPanel)
.background(tokens.colors.surfaceSheet)
.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
@ -186,7 +186,7 @@ private fun EpisodesListSubView(
onEpisodeSelected: (MetaVideo) -> Unit,
onDismiss: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
val groupedEpisodes = remember(episodes) {
episodes
@ -251,14 +251,14 @@ private fun EpisodesListSubView(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.compose_player_panel_episodes),
color = colorScheme.onSurface,
fontSize = 18.sp,
color = tokens.colors.textPrimary,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
@ -270,9 +270,9 @@ private fun EpisodesListSubView(
state = seasonListState,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = tokens.spacing.sheetPadding)
.padding(bottom = tokens.spacing.listGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
items(availableSeasons, key = { season -> season }) { season ->
val label = if (season == 0) {
@ -297,21 +297,21 @@ private fun EpisodesListSubView(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(vertical = NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.compose_player_no_episodes_available),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
LazyColumn(
state = episodeListState,
modifier = Modifier.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
) {
itemsIndexed(
items = seasonEpisodes,
@ -352,27 +352,27 @@ private fun EpisodeRow(
blurUnwatchedEpisodes: Boolean,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clip(tokens.shapes.compactCard)
.background(
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent,
if (isCurrent) tokens.colors.overlaySelected else Color.Transparent,
)
.then(
if (isCurrent) {
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard)
} else {
Modifier
},
)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
.padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
) {
// Thumbnail
if (episode.thumbnail != null) {
@ -380,10 +380,10 @@ private fun EpisodeRow(
model = episode.thumbnail,
contentDescription = null,
modifier = Modifier
.width(80.dp)
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
.width(NuvioTokens.Space.s80)
.height(NuvioTokens.Space.s48)
.clip(tokens.shapes.compactCard)
.then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier),
contentScale = ContentScale.Crop,
)
}
@ -391,7 +391,7 @@ private fun EpisodeRow(
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
val episodeLabel = buildString {
if (episode.season != null && episode.episode != null) {
@ -409,22 +409,22 @@ private fun EpisodeRow(
if (episodeLabel.isNotBlank()) {
Text(
text = episodeLabel,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelXs,
fontWeight = FontWeight.SemiBold,
)
}
if (isCurrent) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(colorScheme.primaryContainer)
.padding(horizontal = 6.dp, vertical = 2.dp),
.clip(tokens.shapes.chip)
.background(tokens.colors.accent)
.padding(horizontal = NuvioTokens.Space.s6, vertical = NuvioTokens.Space.s2),
) {
Text(
text = stringResource(Res.string.compose_player_playing),
color = colorScheme.onPrimaryContainer,
fontSize = 9.sp,
color = tokens.colors.onAccent,
fontSize = NuvioTokens.Type.labelXs,
fontWeight = FontWeight.SemiBold,
)
}
@ -432,8 +432,8 @@ private fun EpisodeRow(
}
Text(
text = episode.title,
color = colorScheme.onSurface,
fontSize = 13.sp,
color = tokens.colors.textPrimary,
fontSize = NuvioTokens.Type.bodySm,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -441,8 +441,8 @@ private fun EpisodeRow(
episode.overview?.let { overview ->
Text(
text = overview,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
color = tokens.colors.textSecondary,
fontSize = NuvioTokens.Type.labelXs,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
@ -462,11 +462,15 @@ private fun EpisodeStreamsSubView(
onReload: () -> Unit,
onDismiss: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
val debridSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val streamBadgeSettings by remember {
StreamBadgeSettingsRepository.ensureLoaded()
StreamBadgeSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val episode = state.selectedEpisode ?: return
val streamsUiState = state.streamsUiState
@ -476,14 +480,14 @@ private fun EpisodeStreamsSubView(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.compose_player_panel_streams),
color = colorScheme.onSurface,
fontSize = 18.sp,
color = tokens.colors.textPrimary,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
@ -493,10 +497,10 @@ private fun EpisodeStreamsSubView(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 8.dp),
.padding(horizontal = tokens.spacing.sheetPadding)
.padding(bottom = tokens.spacing.controlGap),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
PanelChipButton(
label = stringResource(Res.string.action_back),
@ -524,8 +528,8 @@ private fun EpisodeStreamsSubView(
append(episode.title)
}
},
color = colorScheme.onSurfaceVariant,
fontSize = 12.sp,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelSm,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
@ -541,9 +545,9 @@ private fun EpisodeStreamsSubView(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = tokens.spacing.sheetPadding)
.padding(bottom = tokens.spacing.listGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
AddonFilterChip(
label = stringResource(Res.string.collections_tab_all),
@ -569,13 +573,13 @@ private fun EpisodeStreamsSubView(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(vertical = NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = colorScheme.primary,
strokeWidth = 2.dp,
modifier = Modifier.size(28.dp),
color = tokens.colors.accent,
strokeWidth = tokens.borders.medium,
modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4),
)
}
}
@ -584,13 +588,13 @@ private fun EpisodeStreamsSubView(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(vertical = NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.compose_player_no_streams_found),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@ -598,17 +602,22 @@ private fun EpisodeStreamsSubView(
else -> {
val streams = streamsUiState.filteredGroups.flatMap { it.streams }
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
) {
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
) { _, stream ->
EpisodeSourceStreamRow(
StreamCard(
stream = stream,
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
!debridSettings.hasCustomStreamFormatting,
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
showAddonLogo = streamBadgeSettings.showAddonLogo,
badgePlacement = streamBadgeSettings.badgePlacement,
onClick = { onStreamSelected(stream, episode) },
)
}
@ -617,53 +626,3 @@ private fun EpisodeStreamsSubView(
}
}
}
@Composable
private fun EpisodeSourceStreamRow(
stream: StreamItem,
enabled: Boolean,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colorScheme.surfaceVariant.copy(alpha = 0.35f))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stream.streamLabel,
color = colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
stream.streamSubtitle?.let { subtitle ->
if (subtitle != stream.streamLabel) {
Text(
text = subtitle,
color = colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
text = stream.addonName,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -25,6 +25,7 @@ data class PlayerLaunch(
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val streamType: String? = null,
val logo: String? = null,
val poster: String? = null,
val background: String? = null,
@ -48,7 +49,6 @@ data class PlayerLaunch(
val torrentTrackers: List<String> = emptyList(),
val initialPositionMs: Long = 0L,
val initialProgressFraction: Float? = null,
val returnStreamLaunchId: Long? = null,
)
object PlayerLaunchStore {
@ -146,6 +146,15 @@ enum class IosHardwareDecoderMode(
Off("no", "Off"),
}
enum class IosAudioOutputMode(
val mpvValue: String,
val label: String,
) {
Auto("avfoundation,audiounit,", "Auto"),
AvFoundation("avfoundation", "AVFoundation"),
AudioUnit("audiounit", "AudioUnit"),
}
@Composable
fun IosVideoOutputPreset.localizedLabel(): String = when (this) {
IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label)

View file

@ -0,0 +1,280 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamAutoPlaySource
import com.nuvio.app.features.streams.StreamItem
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay(
previousJob: Job?,
nextEpisodeInfo: NextEpisodeInfo?,
allEpisodes: List<MetaVideo>,
parentMetaId: String,
parentMetaType: String,
contentType: String?,
settings: PlayerSettingsUiState,
currentStreamBingeGroup: String?,
onDownloadedEpisodeSelected: (DownloadItem, MetaVideo) -> Unit,
onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit,
onManualSelectionRequired: (MetaVideo) -> Unit,
onSearchingChanged: (Boolean) -> Unit,
onSourceNameChanged: (String?) -> Unit,
onCountdownChanged: (Int?) -> Unit,
onNextEpisodeCardVisibleChanged: (Boolean) -> Unit,
): Job? {
val nextVideoId = nextEpisodeInfo?.videoId ?: return null
val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return null
if (nextEpisodeInfo.hasAired != true) return null
val downloadedNextEpisode = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = nextVideo.season,
episodeNumber = nextVideo.episode,
videoId = nextVideo.id,
)
if (downloadedNextEpisode != null) {
onDownloadedEpisodeSelected(downloadedNextEpisode, nextVideo)
return null
}
previousJob?.cancel()
onSearchingChanged(true)
onSourceNameChanged(null)
onCountdownChanged(null)
val type = contentType ?: parentMetaType
val shouldAutoSelectInManualMode =
settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL &&
(
settings.streamAutoPlayNextEpisodeEnabled ||
settings.streamAutoPlayPreferBingeGroup
)
val bingeGroupOnlyManualMode =
shouldAutoSelectInManualMode &&
!settings.streamAutoPlayNextEpisodeEnabled &&
settings.streamAutoPlayPreferBingeGroup
val effectiveMode = if (shouldAutoSelectInManualMode) {
StreamAutoPlayMode.FIRST_STREAM
} else {
settings.streamAutoPlayMode
}
val effectiveSource = if (shouldAutoSelectInManualMode) {
StreamAutoPlaySource.ALL_SOURCES
} else {
settings.streamAutoPlaySource
}
val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) {
emptySet()
} else {
settings.streamAutoPlaySelectedAddons
}
val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) {
emptySet()
} else {
settings.streamAutoPlaySelectedPlugins
}
val effectiveRegex = if (shouldAutoSelectInManualMode) {
""
} else {
settings.streamAutoPlayRegex
}
val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) {
currentStreamBingeGroup
} else {
null
}
return launch {
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = nextVideo.id,
season = nextVideo.season,
episode = nextVideo.episode,
)
val installedAddonNames = AddonRepository.uiState.value.addons
.enabledAddons()
.map { it.displayTitle }
.toSet()
val debridSettings = DebridSettingsRepository.snapshot()
val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds
var autoSelectTriggered = false
var timeoutElapsed = false
var selectedStream: StreamItem? = null
val autoSelectSettled = CompletableDeferred<Unit>()
fun settleAutoSelect() {
if (!autoSelectSettled.isCompleted) {
autoSelectSettled.complete(Unit)
}
}
fun selectStream(stream: StreamItem) {
autoSelectTriggered = true
selectedStream = stream
settleAutoSelect()
}
fun finishWithoutSelection() {
autoSelectTriggered = true
settleAutoSelect()
}
fun trySelectStream(streams: List<StreamItem>): StreamItem? =
StreamAutoPlaySelector.selectAutoPlayStream(
streams = streams,
mode = effectiveMode,
regexPattern = effectiveRegex,
source = effectiveSource,
installedAddonNames = installedAddonNames,
selectedAddons = effectiveSelectedAddons,
selectedPlugins = effectiveSelectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup,
bingeGroupOnly = bingeGroupOnlyManualMode,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
fun tryBingeGroupOnly(streams: List<StreamItem>): StreamItem? {
if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null
return StreamAutoPlaySelector.selectAutoPlayStream(
streams = streams,
mode = effectiveMode,
regexPattern = effectiveRegex,
source = effectiveSource,
installedAddonNames = installedAddonNames,
selectedAddons = effectiveSelectedAddons,
selectedPlugins = effectiveSelectedPlugins,
preferredBingeGroup = preferredBingeGroup,
preferBingeGroupInSelection = true,
bingeGroupOnly = true,
debridEnabled = debridSettings.canResolvePlayableLinks,
activeResolverProviderId = debridSettings.activeResolverProviderId,
)
}
val innerJob = launch {
PlayerStreamsRepository.episodeStreamsState.collectLatest { state ->
if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest
val allStreams = state.groups.flatMap { it.streams }
if (autoSelectTriggered) {
// Already resolved.
} else if (timeoutElapsed) {
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
} else if (allStreams.isNotEmpty()) {
val earlyMatch = tryBingeGroupOnly(allStreams)
if (earlyMatch != null) {
selectStream(earlyMatch)
}
}
if (!autoSelectTriggered && !state.isAnyLoading) {
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
if (!autoSelectTriggered) {
finishWithoutSelection()
}
return@collectLatest
}
if (autoSelectTriggered) return@collectLatest
}
}
val timeoutMs = timeoutSeconds * 1_000L
val isBoundedTimeout = timeoutSeconds in 1..30
if (isBoundedTimeout) {
delay(timeoutMs)
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
val candidate = trySelectStream(allStreams)
if (candidate != null) {
selectStream(candidate)
}
}
}
if (selectedStream != null) {
innerJob.cancel()
} else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) {
innerJob.cancel()
finishWithoutSelection()
} else {
val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() }
innerJob.cancel()
if (completed == null && !autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
selectedStream = trySelectStream(allStreams)
}
finishWithoutSelection()
}
}
} else {
timeoutElapsed = true
if (!autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
trySelectStream(allStreams)?.let(::selectStream)
}
}
val completed = withTimeoutOrNull(NEXT_EPISODE_HARD_TIMEOUT_MS) { autoSelectSettled.await() }
innerJob.cancel()
if (completed == null && !autoSelectTriggered) {
val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }
if (allStreams.isNotEmpty()) {
selectedStream = trySelectStream(allStreams)
}
finishWithoutSelection()
}
}
onSearchingChanged(false)
val selected = selectedStream
if (selected != null) {
onSourceNameChanged(selected.addonName)
for (i in 3 downTo 1) {
onCountdownChanged(i)
delay(1000)
}
onEpisodeStreamSelected(selected, nextVideo)
onNextEpisodeCardVisibleChanged(false)
onCountdownChanged(null)
onSourceNameChanged(null)
} else {
onManualSelectionRequired(nextVideo)
onNextEpisodeCardVisibleChanged(false)
}
}
}

View file

@ -47,6 +47,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -122,6 +125,8 @@ internal fun OpeningOverlay(
),
label = "openingOverlayContentScale",
)
var logoLoadError by remember(logo) { mutableStateOf(false) }
val logoUrl = logo?.takeIf { it.isNotBlank() }
Box(
modifier = modifier
@ -178,14 +183,14 @@ internal fun OpeningOverlay(
label = "openingOverlayP2pProgress",
)
val progressActive = targetProgress != null
if (logo != null) {
if (logoUrl != null && !logoLoadError) {
Box(
modifier = Modifier
.width(300.dp)
.height(180.dp),
) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
@ -197,10 +202,11 @@ internal fun OpeningOverlay(
}
},
contentScale = ContentScale.Fit,
onError = { logoLoadError = true },
)
if (progressActive) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
@ -377,6 +383,9 @@ internal fun PauseMetadataOverlay(
horizontalSafePadding: Dp,
modifier: Modifier = Modifier,
) {
var logoLoadError by remember(logo) { mutableStateOf(false) }
val logoUrl = logo?.takeIf { it.isNotBlank() }
BoxWithConstraints(
modifier = modifier
.background(
@ -429,13 +438,14 @@ internal fun PauseMetadataOverlay(
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(if (compactHeight) 8.dp else 12.dp))
if (!logo.isNullOrBlank()) {
if (logoUrl != null && !logoLoadError) {
AsyncImage(
model = logo,
model = logoUrl,
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.BottomStart,
modifier = Modifier.height(logoHeight),
onError = { logoLoadError = true },
)
} else {
Text(

View file

@ -0,0 +1,165 @@
package com.nuvio.app.features.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.p2p.P2pLoadingStatus
import com.nuvio.app.features.player.skip.NextEpisodeCard
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.SkipIntroButton
import com.nuvio.app.features.player.skip.SkipInterval
@Composable
internal fun BoxScope.PlayerPlaybackOverlays(
playerControlsLocked: Boolean,
lockedOverlayVisible: Boolean,
playbackSnapshot: PlayerPlaybackSnapshot,
displayedPositionMs: Long,
metrics: PlayerLayoutMetrics,
horizontalSafePadding: Dp,
onUnlock: () -> Unit,
showOpeningOverlay: Boolean,
backdropArtwork: String?,
logo: String?,
title: String,
onBackWithProgress: () -> Unit,
p2pInitialLoadingMessage: String?,
p2pInitialLoadingProgress: Float?,
showP2pRebufferStats: Boolean,
p2pRebufferMessage: String?,
p2pRebufferProgress: Float?,
currentGestureFeedback: GestureFeedbackState?,
renderedGestureFeedback: GestureFeedbackState?,
initialLoadCompleted: Boolean,
pausedOverlayVisible: Boolean,
activeSkipInterval: SkipInterval?,
skipIntervalDismissed: Boolean,
controlsVisible: Boolean,
onSkipInterval: (SkipInterval) -> Unit,
onDismissSkipInterval: () -> Unit,
sliderEdgePadding: Dp,
overlayBottomPadding: Dp,
isSeries: Boolean,
nextEpisodeInfo: NextEpisodeInfo?,
showNextEpisodeCard: Boolean,
nextEpisodeAutoPlaySearching: Boolean,
nextEpisodeAutoPlaySourceName: String?,
nextEpisodeAutoPlayCountdown: Int?,
onPlayNextEpisode: () -> Unit,
onDismissNextEpisode: () -> Unit,
errorMessage: String?,
onDismissError: () -> Unit,
) {
AnimatedVisibility(
visible = playerControlsLocked && lockedOverlayVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
LockedPlayerOverlay(
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
onUnlock = onUnlock,
modifier = Modifier.fillMaxSize(),
)
}
AnimatedVisibility(
visible = showOpeningOverlay,
enter = fadeIn(),
exit = fadeOut(),
) {
OpeningOverlay(
artwork = backdropArtwork,
logo = logo,
title = title,
onBack = onBackWithProgress,
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
message = p2pInitialLoadingMessage,
progress = p2pInitialLoadingProgress,
)
}
P2pLoadingStatus(
visible = showP2pRebufferStats && errorMessage == null,
message = p2pRebufferMessage,
progress = p2pRebufferProgress,
modifier = Modifier
.align(Alignment.Center)
.padding(top = 58.dp),
)
AnimatedVisibility(
visible = currentGestureFeedback != null,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier.fillMaxSize(),
) {
renderedGestureFeedback?.let { feedback ->
GestureFeedbackPill(
feedback = feedback,
modifier = Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top))
.padding(horizontal = horizontalSafePadding)
.padding(top = 40.dp),
)
}
}
}
if (!playerControlsLocked) {
SkipIntroButton(
interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval,
dismissed = skipIntervalDismissed,
controlsVisible = controlsVisible,
onSkip = {
activeSkipInterval?.let(onSkipInterval)
},
onDismiss = onDismissSkipInterval,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
)
}
if (isSeries && !playerControlsLocked) {
NextEpisodeCard(
nextEpisode = nextEpisodeInfo,
visible = showNextEpisodeCard,
isAutoPlaySearching = nextEpisodeAutoPlaySearching,
autoPlaySourceName = nextEpisodeAutoPlaySourceName,
autoPlayCountdownSec = nextEpisodeAutoPlayCountdown,
onPlayNext = onPlayNextEpisode,
onDismiss = onDismissNextEpisode,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = sliderEdgePadding, bottom = overlayBottomPadding),
)
}
if (errorMessage != null) {
ErrorModal(
message = errorMessage,
onDismiss = onDismissError,
)
}
}

View file

@ -0,0 +1,38 @@
package com.nuvio.app.features.player
import androidx.compose.ui.Modifier
internal data class PlayerScreenArgs(
val title: String,
val sourceUrl: String,
val sourceAudioUrl: String?,
val sourceHeaders: Map<String, String>,
val sourceResponseHeaders: Map<String, String>,
val streamType: String?,
val providerName: String,
val streamTitle: String,
val streamSubtitle: String?,
val initialBingeGroup: String?,
val pauseDescription: String?,
val onBack: () -> Unit,
val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?,
val modifier: Modifier,
val logo: String?,
val poster: String?,
val background: String?,
val seasonNumber: Int?,
val episodeNumber: Int?,
val episodeTitle: String?,
val episodeThumbnail: String?,
val contentType: String?,
val videoId: String?,
val parentMetaId: String,
val parentMetaType: String,
val providerAddonId: String?,
val torrentInfoHash: String?,
val torrentFileIdx: Int?,
val torrentFilename: String?,
val torrentTrackers: List<String>,
val initialPositionMs: Long,
val initialProgressFraction: Float?,
)

View file

@ -0,0 +1,146 @@
package com.nuvio.app.features.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_player_airs_prefix
import nuvio.composeapp.generated.resources.compose_player_downloaded
import nuvio.composeapp.generated.resources.compose_player_resize_fill
import nuvio.composeapp.generated.resources.compose_player_resize_fit
import nuvio.composeapp.generated.resources.compose_player_resize_zoom
import nuvio.composeapp.generated.resources.generic_unknown
import nuvio.composeapp.generated.resources.parental_alcohol
import nuvio.composeapp.generated.resources.parental_frightening
import nuvio.composeapp.generated.resources.parental_nudity
import nuvio.composeapp.generated.resources.parental_profanity
import nuvio.composeapp.generated.resources.parental_severity_mild
import nuvio.composeapp.generated.resources.parental_severity_moderate
import nuvio.composeapp.generated.resources.parental_severity_severe
import nuvio.composeapp.generated.resources.parental_violence
import nuvio.composeapp.generated.resources.compose_player_tba
import org.jetbrains.compose.resources.stringResource
@Composable
internal fun PlayerScreenContent(args: PlayerScreenArgs) {
LockPlayerToLandscape()
val playerSettingsUiState by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val p2pSettingsUiState by remember {
P2pSettingsRepository.ensureLoaded()
P2pSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember {
MetaScreenSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle()
val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle()
val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle()
val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle()
val runtime = remember { PlayerScreenRuntime(args) }
runtime.args = args
BoxWithConstraints(
modifier = args.modifier
.fillMaxSize()
.background(Color.Black),
) {
val density = LocalDensity.current
val horizontalSafePadding = playerHorizontalSafePadding()
val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) }
runtime.scope = rememberCoroutineScope()
runtime.hapticFeedback = LocalHapticFeedback.current
runtime.gestureController = rememberPlayerGestureController()
runtime.playerSettingsUiState = playerSettingsUiState
runtime.p2pSettingsUiState = p2pSettingsUiState
runtime.p2pStreamingState = p2pStreamingState
runtime.metaScreenSettingsUiState = metaScreenSettingsUiState
runtime.watchedUiState = watchedUiState
runtime.watchProgressUiState = watchProgressUiState
runtime.sourceStreamsState = sourceStreamsState
runtime.episodeStreamsRepoState = episodeStreamsRepoState
runtime.metaUiState = metaUiState
runtime.addonsUiState = addonsUiState
runtime.addonSubtitles = addonSubtitles
runtime.isLoadingAddonSubtitles = isLoadingAddonSubtitles
runtime.horizontalSafePadding = horizontalSafePadding
runtime.metrics = metrics
runtime.sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding
runtime.overlayBottomPadding = sliderOverlayBottomPadding(metrics)
runtime.sideGestureSystemEdgeExclusionPx = with(density) {
PlayerSideGestureSystemEdgeExclusion.toPx()
}
runtime.resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit)
runtime.resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill)
runtime.resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom)
runtime.downloadedLabel = stringResource(Res.string.compose_player_downloaded)
runtime.airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
runtime.tbaLabel = stringResource(Res.string.compose_player_tba)
runtime.genericUnknownLabel = stringResource(Res.string.generic_unknown)
runtime.parentalGuideLabels = ParentalGuideLabels(
nudity = stringResource(Res.string.parental_nudity),
violence = stringResource(Res.string.parental_violence),
profanity = stringResource(Res.string.parental_profanity),
alcohol = stringResource(Res.string.parental_alcohol),
frightening = stringResource(Res.string.parental_frightening),
severe = stringResource(Res.string.parental_severity_severe),
moderate = stringResource(Res.string.parental_severity_moderate),
mild = stringResource(Res.string.parental_severity_mild),
)
if (runtime.playerMetaVideos.isEmpty()) {
runtime.playerMetaVideos = MetaDetailsRepository.peek(
args.parentMetaType,
args.parentMetaId,
)?.videos ?: emptyList()
}
if (runtime.lastSyncedSettingsResizeMode != playerSettingsUiState.resizeMode) {
runtime.resizeMode = playerSettingsUiState.resizeMode
runtime.lastSyncedSettingsResizeMode = playerSettingsUiState.resizeMode
}
runtime.resetIdentityStateIfNeeded()
val keepScreenAwake = runtime.errorMessage == null &&
(runtime.playbackSnapshot.isPlaying ||
(runtime.shouldPlay && runtime.playbackSnapshot.isLoading))
EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake)
ManagePlayerPictureInPicture(
isPlaying = runtime.playbackSnapshot.isPlaying,
playerSize = runtime.layoutSize,
)
runtime.BindPlayerRuntimeEffects()
runtime.RenderPlayerRuntimeUi()
}
}

View file

@ -0,0 +1,236 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import com.nuvio.app.features.details.MetaDetailsUiState
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.p2p.P2pConsentDialog
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@Composable
internal fun PlayerScreenModalHosts(
pendingP2pSwitch: PendingPlayerP2pSwitch?,
onPendingP2pSwitchChanged: (PendingPlayerP2pSwitch?) -> Unit,
onP2pEpisodeStreamSelected: (StreamItem, MetaVideo, Boolean) -> Unit,
onP2pSourceStreamSelected: (StreamItem) -> Unit,
onNextEpisodeAutoPlaySearchingChanged: (Boolean) -> Unit,
onNextEpisodeAutoPlayCountdownChanged: (Int?) -> Unit,
onNextEpisodeAutoPlaySourceNameChanged: (String?) -> Unit,
showAudioModal: Boolean,
audioTracks: List<AudioTrack>,
selectedAudioIndex: Int,
onAudioTrackSelected: (Int) -> Unit,
onAudioModalDismissed: () -> Unit,
showSubtitleModal: Boolean,
activeSubtitleTab: SubtitleTab,
subtitleTracks: List<SubtitleTrack>,
selectedSubtitleIndex: Int,
addonSubtitles: List<AddonSubtitle>,
selectedAddonSubtitleId: String?,
isLoadingAddonSubtitles: Boolean,
subtitleStyle: SubtitleStyleState,
subtitleDelayMs: Int,
selectedAddonSubtitle: AddonSubtitle?,
subtitleAutoSyncState: SubtitleAutoSyncUiState,
onSubtitleTabSelected: (SubtitleTab) -> Unit,
onBuiltInSubtitleTrackSelected: (Int) -> Unit,
onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
onFetchAddonSubtitles: () -> Unit,
onSubtitleStyleChanged: (SubtitleStyleState) -> Unit,
onSubtitleDelayChanged: (Int) -> Unit,
onSubtitleDelayReset: () -> Unit,
onAutoSyncCapture: () -> Unit,
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
onAutoSyncReload: () -> Unit,
onSubtitleModalDismissed: () -> Unit,
showVideoSettingsModal: Boolean,
playerSettings: PlayerSettingsUiState,
onVideoSettingsChanged: () -> Unit,
onVideoSettingsModalDismissed: () -> Unit,
showSourcesPanel: Boolean,
sourceStreamsState: StreamsUiState,
activeSourceUrl: String,
activeStreamTitle: String,
onSourceFilterSelected: (String?) -> Unit,
onSourceStreamSelected: (StreamItem) -> Unit,
onReloadSources: () -> Unit,
onSourcesPanelDismissed: () -> Unit,
isSeries: Boolean,
showEpisodesPanel: Boolean,
allEpisodes: List<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
activeSeasonNumber: Int?,
activeEpisodeNumber: Int?,
watchProgressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
episodeStreamsPanelState: EpisodeStreamsPanelState,
episodeStreamsRepoState: StreamsUiState,
onEpisodeSelectedForDownload: (MetaVideo) -> Boolean,
onEpisodeStreamsRequested: (MetaVideo) -> Unit,
onEpisodeStreamFilterSelected: (String?) -> Unit,
onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit,
onBackToEpisodes: () -> Unit,
onReloadEpisodeStreams: () -> Unit,
onEpisodesPanelDismissed: () -> Unit,
showSubmitIntroModal: Boolean,
activeVideoId: String?,
metaUiState: MetaDetailsUiState,
displayedPositionMs: Long,
submitIntroSegmentType: String,
onSubmitIntroSegmentTypeChanged: (String) -> Unit,
submitIntroStartTimeStr: String,
onSubmitIntroStartTimeChanged: (String) -> Unit,
submitIntroEndTimeStr: String,
onSubmitIntroEndTimeChanged: (String) -> Unit,
onSubmitIntroDismissed: () -> Unit,
onSubmitIntroSuccess: () -> Unit,
) {
if (pendingP2pSwitch != null) {
P2pConsentDialog(
onEnableP2p = {
val pending = pendingP2pSwitch
onPendingP2pSwitchChanged(null)
P2pSettingsRepository.setP2pEnabled(true)
val episode = pending.episode
if (episode != null) {
onP2pEpisodeStreamSelected(pending.stream, episode, pending.isAutoPlay)
} else {
onP2pSourceStreamSelected(pending.stream)
}
},
onDismiss = {
if (pendingP2pSwitch.isAutoPlay) {
onNextEpisodeAutoPlaySearchingChanged(false)
onNextEpisodeAutoPlayCountdownChanged(null)
onNextEpisodeAutoPlaySourceNameChanged(null)
}
onPendingP2pSwitchChanged(null)
},
)
}
AudioTrackModal(
visible = showAudioModal,
audioTracks = audioTracks,
selectedIndex = selectedAudioIndex,
onTrackSelected = onAudioTrackSelected,
onDismiss = onAudioModalDismissed,
)
SubtitleModal(
visible = showSubtitleModal,
activeTab = activeSubtitleTab,
subtitleTracks = subtitleTracks,
selectedSubtitleIndex = selectedSubtitleIndex,
addonSubtitles = addonSubtitles,
selectedAddonSubtitleId = selectedAddonSubtitleId,
isLoadingAddonSubtitles = isLoadingAddonSubtitles,
subtitleStyle = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = selectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
onTabSelected = onSubtitleTabSelected,
onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected,
onAddonSubtitleSelected = onAddonSubtitleSelected,
onFetchAddonSubtitles = onFetchAddonSubtitles,
onStyleChanged = onSubtitleStyleChanged,
onSubtitleDelayChanged = onSubtitleDelayChanged,
onSubtitleDelayReset = onSubtitleDelayReset,
onAutoSyncCapture = onAutoSyncCapture,
onAutoSyncCueSelected = onAutoSyncCueSelected,
onAutoSyncReload = onAutoSyncReload,
onDismiss = onSubtitleModalDismissed,
)
IosVideoSettingsModal(
visible = showVideoSettingsModal,
settings = playerSettings,
onSettingsChanged = onVideoSettingsChanged,
onDismiss = onVideoSettingsModalDismissed,
)
PlayerSourcesPanel(
visible = showSourcesPanel,
streamsUiState = sourceStreamsState,
currentStreamUrl = activeSourceUrl,
currentStreamName = activeStreamTitle,
onFilterSelected = onSourceFilterSelected,
onStreamSelected = onSourceStreamSelected,
onReload = onReloadSources,
onDismiss = onSourcesPanelDismissed,
)
if (isSeries) {
PlayerEpisodesPanel(
visible = showEpisodesPanel,
episodes = allEpisodes,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
currentSeason = activeSeasonNumber,
currentEpisode = activeEpisodeNumber,
progressByVideoId = watchProgressByVideoId,
watchedKeys = watchedKeys,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
episodeStreamsState = episodeStreamsPanelState.copy(
streamsUiState = episodeStreamsRepoState,
),
onSeasonSelected = { },
onEpisodeSelected = { episode ->
if (!onEpisodeSelectedForDownload(episode)) {
onEpisodeStreamsRequested(episode)
}
},
onEpisodeStreamFilterSelected = onEpisodeStreamFilterSelected,
onEpisodeStreamSelected = onEpisodeStreamSelected,
onBackToEpisodes = onBackToEpisodes,
onReloadEpisodeStreams = onReloadEpisodeStreams,
onDismiss = onEpisodesPanelDismissed,
)
}
val season = activeSeasonNumber
val episode = activeEpisodeNumber
val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") }
?: parentMetaId.takeIf { it.startsWith("tt") }
?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") }
if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) {
com.nuvio.app.features.player.skip.SubmitIntroDialog(
imdbId = imdbId,
season = season,
episode = episode,
currentTimeSec = displayedPositionMs / 1000.0,
segmentType = submitIntroSegmentType,
onSegmentTypeChange = onSubmitIntroSegmentTypeChanged,
startTimeStr = submitIntroStartTimeStr,
onStartTimeChange = onSubmitIntroStartTimeChanged,
endTimeStr = submitIntroEndTimeStr,
onEndTimeChange = onSubmitIntroEndTimeChanged,
onDismiss = onSubmitIntroDismissed,
onSuccess = onSubmitIntroSuccess,
)
}
}
internal fun selectDownloadedEpisodeForPlayback(
parentMetaId: String,
episode: MetaVideo,
onDownloadedEpisodeSelected: (com.nuvio.app.features.downloads.DownloadItem, MetaVideo) -> Unit,
): Boolean {
val downloadedEpisode = DownloadsRepository.findPlayableDownload(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
videoId = episode.id,
)
if (downloadedEpisode != null) {
onDownloadedEpisodeSelected(downloadedEpisode, episode)
return true
}
return false
}

View file

@ -0,0 +1,604 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.p2p.P2pStreamRequest
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules
import com.nuvio.app.features.player.skip.SkipIntroRepository
import com.nuvio.app.features.streams.BingeGroupCacheRepository
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Composable
internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() {
val currentFeedback = liveGestureFeedback ?: gestureFeedback
LaunchedEffect(currentFeedback) {
if (currentFeedback != null) {
renderedGestureFeedback = currentFeedback
}
}
LaunchedEffect(parentMetaType, parentMetaId) {
playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList()
if (playerMetaVideos.isEmpty()) {
playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
}
}
LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) {
val currentMeta = metaUiState.meta ?: return@LaunchedEffect
if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) {
playerMetaVideos = currentMeta.videos
}
}
LaunchedEffect(currentStreamBingeGroup, parentMetaId) {
val bg = currentStreamBingeGroup
if (bg != null && parentMetaId.isNotBlank()) {
BingeGroupCacheRepository.save(parentMetaId, bg)
}
}
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
errorMessage = null
playerController = null
playerControllerSourceUrl = null
playbackSnapshot = PlayerPlaybackSnapshot()
isScrubbingTimeline = false
scrubbingPositionMs = null
liveGestureFeedback = null
renderedGestureFeedback = null
lockedOverlayVisible = false
credentialRefreshJob?.cancel()
credentialRefreshJob = null
credentialRefreshAttemptedSourceUrl = null
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
seekProgressSyncJob?.cancel()
seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
accumulatedSeekResetJob = null
accumulatedSeekState = null
speedBoostRestoreSpeed = null
preferredAudioSelectionApplied = false
preferredSubtitleSelectionApplied = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
SubtitleRepository.clear()
WatchProgressRepository.ensureLoaded()
}
LaunchedEffect(
activeTorrentInfoHash,
activeTorrentFileIdx,
activeTorrentFilename,
activeTorrentTrackers,
p2pSettingsUiState.p2pEnabled,
) {
val infoHash = activeTorrentInfoHash
if (infoHash == null) {
p2pResolvedSourceUrl = null
P2pStreamingEngine.stopStream()
return@LaunchedEffect
}
if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) {
return@LaunchedEffect
}
p2pResolvedSourceUrl = null
val requestedFileIdx = activeTorrentFileIdx
val requestedFilename = activeTorrentFilename
val requestedTrackers = activeTorrentTrackers
errorMessage = null
playerController = null
playerControllerSourceUrl = null
playbackSnapshot = PlayerPlaybackSnapshot()
initialLoadCompleted = false
try {
val localUrl = P2pStreamingEngine.startStream(
P2pStreamRequest(
infoHash = infoHash,
fileIdx = requestedFileIdx,
filename = requestedFilename,
trackers = requestedTrackers,
),
)
if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) {
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
p2pResolvedSourceUrl = localUrl
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
errorMessage = getString(
Res.string.player_error_failed_start_torrent,
error.message ?: genericUnknownLabel,
)
controlsVisible = !playerControlsLocked
initialLoadCompleted = true
}
}
LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) {
val state = p2pStreamingState
if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) {
errorMessage = getString(Res.string.player_error_torrent, state.message)
controlsVisible = !playerControlsLocked
}
}
LaunchedEffect(playbackSession.videoId) {
subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0
subtitleAutoSyncState = SubtitleAutoSyncUiState()
}
LaunchedEffect(playerController, subtitleDelayMs) {
playerController?.setSubtitleDelayMs(subtitleDelayMs)
}
LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) {
subtitleAutoSyncState = SubtitleAutoSyncUiState()
}
LaunchedEffect(playerController, subtitleStyle) {
playerController?.applySubtitleStyle(subtitleStyle)
}
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
return@LaunchedEffect
}
if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
autoFetchedAddonSubtitlesForKey = fetchKey
fetchAddonSubtitlesForActiveItem()
}
LaunchedEffect(playbackSnapshot.isLoading, playerController) {
if (!playbackSnapshot.isLoading && playerController != null) {
refreshTracks()
}
}
LaunchedEffect(
playerController,
playbackSnapshot.isLoading,
preferredAudioSelectionApplied,
preferredSubtitleSelectionApplied,
) {
if (playerController == null || playbackSnapshot.isLoading) {
return@LaunchedEffect
}
if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
return@LaunchedEffect
}
repeat(10) {
refreshTracks()
if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) {
return@LaunchedEffect
}
delay(300)
}
}
LaunchedEffect(
playerController,
playerControllerSourceUrl,
playbackSnapshot.isLoading,
playbackSnapshot.durationMs,
activeInitialPositionMs,
activeInitialProgressFraction,
initialSeekApplied,
) {
val controller = playerController ?: return@LaunchedEffect
if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect
if (initialSeekApplied || playbackSnapshot.isLoading) return@LaunchedEffect
val progressFraction = activeInitialProgressFraction
?.takeIf { it > 0f }
?.coerceIn(0f, 1f)
val targetPositionMs = when {
activeInitialPositionMs > 0L -> activeInitialPositionMs
progressFraction != null && playbackSnapshot.durationMs > 0L -> {
(playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong()
}
progressFraction != null -> return@LaunchedEffect
else -> 0L
}
if (targetPositionMs <= 0L) {
initialSeekApplied = true
return@LaunchedEffect
}
controller.seekTo(targetPositionMs)
initialSeekApplied = true
}
BindPlayerUiVisibilityEffects()
BindPlayerMetadataAndSkipEffects()
DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) {
val effectVideoId = playbackSession.videoId
val effectSourceUrl = activeSourceUrl
val effectSourceAudioUrl = activeSourceAudioUrl
onDispose {
if (
playbackSession.videoId == effectVideoId &&
activeSourceUrl == effectSourceUrl &&
activeSourceAudioUrl == effectSourceAudioUrl
) {
flushWatchProgress()
}
}
}
DisposableEffect(Unit) {
onDispose {
P2pStreamingEngine.shutdown()
PlayerStreamsRepository.clearAll()
}
}
}
@Composable
private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() {
LaunchedEffect(
controlsVisible,
isScrubbingTimeline,
playbackSnapshot.isPlaying,
playbackSnapshot.isLoading,
showParentalGuide,
errorMessage,
) {
if (
!controlsVisible ||
isScrubbingTimeline ||
!playbackSnapshot.isPlaying ||
playbackSnapshot.isLoading ||
showParentalGuide ||
errorMessage != null
) {
return@LaunchedEffect
}
delay(3500)
controlsVisible = false
}
LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
if (!playerControlsLocked || !lockedOverlayVisible) return@LaunchedEffect
delay(PlayerLockedOverlayDurationMs)
lockedOverlayVisible = false
}
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
pausedOverlayVisible = false
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
return@LaunchedEffect
}
delay(5000)
pausedOverlayVisible = true
}
LaunchedEffect(
playbackSnapshot.positionMs,
playbackSnapshot.isPlaying,
playbackSnapshot.isLoading,
playbackSnapshot.isEnded,
playbackSnapshot.durationMs,
) {
if (playbackSnapshot.isEnded) {
flushWatchProgress()
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
pendingScrobbleStartAfterSeek = false
flushWatchProgress()
}
if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
}
if (!playbackSnapshot.isLoading) {
previousIsPlaying = playbackSnapshot.isPlaying
}
if (playbackSnapshot.isPlaying) {
persistPlaybackProgressTick()
}
}
}
@Composable
private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() {
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) {
parentalWarnings = emptyList()
showParentalGuide = false
parentalGuideHasShown = false
playbackStartedForParentalGuide = false
val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect
val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect
parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels)
if (playbackSnapshot.isPlaying) {
tryShowParentalGuide()
}
}
LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) {
if (playbackSnapshot.isPlaying) {
tryShowParentalGuide()
}
}
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
skipIntervals = emptyList()
activeSkipInterval = null
skipIntervalDismissed = false
showNextEpisodeCard = false
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = false
val season = activeSeasonNumber
val episode = activeEpisodeNumber
val vid = activeVideoId
if (season == null || episode == null || vid == null) return@LaunchedEffect
launch {
val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") }
val intervals = SkipIntroRepository.getSkipIntervals(
imdbId = imdbId,
season = season,
episode = episode,
)
skipIntervals = intervals
}
}
LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) {
if (skipIntervals.isEmpty()) {
activeSkipInterval = null
return@LaunchedEffect
}
val positionSec = playbackSnapshot.positionMs / 1000.0
val current = skipIntervals.firstOrNull { interval ->
positionSec >= interval.startTime && positionSec < interval.endTime
}
if (current != activeSkipInterval) {
activeSkipInterval = current
if (current != null) skipIntervalDismissed = false
}
}
LaunchedEffect(playerMetaVideos, activeSeasonNumber, activeEpisodeNumber) {
if (!isSeries || playerMetaVideos.isEmpty()) {
nextEpisodeInfo = null
return@LaunchedEffect
}
val curSeason = activeSeasonNumber ?: return@LaunchedEffect
val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect
val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode(
videos = playerMetaVideos,
currentSeason = curSeason,
currentEpisode = curEpisode,
)
val nextSeason = nextVideo?.season
val nextEpisode = nextVideo?.episode
nextEpisodeInfo = if (nextVideo != null && nextSeason != null && nextEpisode != null) {
NextEpisodeInfo(
videoId = nextVideo.id,
season = nextSeason,
episode = nextEpisode,
title = nextVideo.title,
thumbnail = nextVideo.thumbnail,
overview = nextVideo.overview,
released = nextVideo.released,
hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released),
unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) {
"$airsPrefix ${nextVideo.released ?: tbaLabel}"
} else null,
)
} else null
}
LaunchedEffect(
playbackSnapshot.positionMs,
playbackSnapshot.durationMs,
nextEpisodeInfo,
skipIntervals,
playerSettingsUiState.nextEpisodeThresholdMode,
playerSettingsUiState.nextEpisodeThresholdPercent,
playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
) {
if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) {
showNextEpisodeCard = false
return@LaunchedEffect
}
val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard(
positionMs = playbackSnapshot.positionMs,
durationMs = playbackSnapshot.durationMs,
skipIntervals = skipIntervals,
thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode,
thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent,
thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd,
)
if (shouldShow && !showNextEpisodeCard) {
showNextEpisodeCard = true
if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
playNextEpisode()
}
} else if (!shouldShow) {
showNextEpisodeCard = false
}
}
LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) {
if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) {
showNextEpisodeCard = true
if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) {
playNextEpisode()
}
}
}
}
internal fun PlayerScreenRuntime.removeFailedStreamFromCache() {
val currentVideoId = activeVideoId ?: return
val cacheKey = StreamLinkCacheRepository.contentKey(
type = contentType ?: parentMetaType,
videoId = currentVideoId,
parentMetaId = parentMetaId,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.remove(cacheKey)
}
internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message: String?): Boolean {
val failedUrl = activeSourceUrl
if (!failedUrl.hasLikelyExpiringPlaybackCredentials()) return false
if (credentialRefreshJob?.isActive == true) return true
if (credentialRefreshAttemptedSourceUrl == failedUrl) return false
val currentVideoId = activeVideoId ?: return false
credentialRefreshAttemptedSourceUrl = failedUrl
removeFailedStreamFromCache()
val savedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
val expectedProviderAddonId = activeProviderAddonId
val expectedProviderName = activeProviderName
val expectedStreamTitle = activeStreamTitle
val expectedBingeGroup = currentStreamBingeGroup
val type = contentType ?: parentMetaType
val season = activeSeasonNumber
val episode = activeEpisodeNumber
errorMessage = null
controlsVisible = !playerControlsLocked
credentialRefreshJob = scope.launch {
PlayerStreamsRepository.loadSources(
type = type,
videoId = currentVideoId,
season = season,
episode = episode,
forceRefresh = true,
)
var refreshedStream: StreamItem? = null
var pollCount = 0
while (pollCount < CREDENTIAL_REFRESH_POLL_COUNT && refreshedStream == null) {
val state = PlayerStreamsRepository.sourceState.value
refreshedStream = findCredentialRefreshCandidate(
streams = state.groups.flatMap { it.streams },
failedUrl = failedUrl,
expectedProviderAddonId = expectedProviderAddonId,
expectedProviderName = expectedProviderName,
expectedStreamTitle = expectedStreamTitle,
expectedBingeGroup = expectedBingeGroup,
)
if (
refreshedStream != null ||
state.emptyStateReason != null ||
(!state.isAnyLoading && state.groups.isNotEmpty())
) {
break
}
delay(CREDENTIAL_REFRESH_POLL_INTERVAL_MS)
pollCount++
}
val stream = refreshedStream
if (stream == null) {
errorMessage = message
controlsVisible = !playerControlsLocked
return@launch
}
val refreshedUrl = stream.playableDirectUrl
if (refreshedUrl.isNullOrBlank() || refreshedUrl == failedUrl) {
errorMessage = message
controlsVisible = !playerControlsLocked
return@launch
}
flushWatchProgress()
stopActiveP2pStream()
activeSourceUrl = refreshedUrl
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
activeProviderAddonId = stream.addonId
currentStreamBingeGroup = stream.behaviorHints.bingeGroup
activeInitialPositionMs = savedPositionMs
activeInitialProgressFraction = null
showSourcesPanel = false
controlsVisible = true
}
return true
}
private fun findCredentialRefreshCandidate(
streams: List<StreamItem>,
failedUrl: String,
expectedProviderAddonId: String?,
expectedProviderName: String,
expectedStreamTitle: String,
expectedBingeGroup: String?,
): StreamItem? =
streams
.asSequence()
.mapNotNull { stream ->
val refreshedUrl = stream.playableDirectUrl?.takeIf { it.isNotBlank() && it != failedUrl }
?: return@mapNotNull null
val providerMatches = if (!expectedProviderAddonId.isNullOrBlank()) {
stream.addonId == expectedProviderAddonId
} else {
stream.addonName == expectedProviderName
}
if (!providerMatches) return@mapNotNull null
var score = 100
if (stream.streamLabel == expectedStreamTitle) score += 40
if (!expectedBingeGroup.isNullOrBlank() && stream.behaviorHints.bingeGroup == expectedBingeGroup) {
score += 20
}
if (refreshedUrl.hasLikelyExpiringPlaybackCredentials()) score += 5
score to stream
}
.maxByOrNull { (score, _) -> score }
?.second
private const val CREDENTIAL_REFRESH_POLL_COUNT = 30
private const val CREDENTIAL_REFRESH_POLL_INTERVAL_MS = 500L

View file

@ -0,0 +1,316 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import kotlin.math.abs
import kotlin.math.roundToInt
internal data class PlayerSurfaceGestureCallbacks(
val onSurfaceTap: State<(Offset) -> Unit>,
val onSurfaceDoubleTap: State<(Offset) -> Unit>,
val activateHoldToSpeed: State<() -> Unit>,
val deactivateHoldToSpeed: State<() -> Unit>,
val showHorizontalSeekPreview: State<(Long, Long) -> Unit>,
val showBrightnessFeedback: State<(Float) -> Unit>,
val showVolumeFeedback: State<(PlayerAudioLevel) -> Unit>,
val clearLiveGestureFeedback: State<() -> Unit>,
val revealLockedOverlay: State<() -> Unit>,
val isHoldToSpeedGestureActive: State<Boolean>,
val touchGesturesEnabled: State<Boolean>,
val playerControlsLocked: State<Boolean>,
val currentPositionMs: State<Long>,
val currentDurationMs: State<Long>,
val commitHorizontalSeek: State<(Long) -> Unit>,
)
internal fun PlayerScreenRuntime.showGestureFeedback(feedback: GestureFeedbackState) {
gestureMessageJob?.cancel()
gestureFeedback = feedback
gestureMessageJob = scope.launch {
delay(900)
gestureFeedback = null
}
}
internal fun PlayerScreenRuntime.showGestureMessage(message: String) {
showGestureFeedback(GestureFeedbackState(message = message))
}
internal fun PlayerScreenRuntime.clearLiveGestureFeedback() {
liveGestureFeedback = null
}
internal fun PlayerScreenRuntime.revealLockedOverlay() {
controlsVisible = false
lockedOverlayVisible = true
}
internal fun PlayerScreenRuntime.lockPlayerControls() {
playerControlsLocked = true
controlsVisible = false
lockedOverlayVisible = false
pausedOverlayVisible = false
isScrubbingTimeline = false
scrubbingPositionMs = null
gestureMessageJob?.cancel()
gestureFeedback = null
liveGestureFeedback = null
renderedGestureFeedback = null
showAudioModal = false
showSubtitleModal = false
showVideoSettingsModal = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
}
internal fun PlayerScreenRuntime.unlockPlayerControls() {
playerControlsLocked = false
lockedOverlayVisible = false
controlsVisible = true
}
internal fun PlayerScreenRuntime.showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
val seconds = amountMs / 1000L
if (seconds <= 0L) return
showGestureFeedback(
GestureFeedbackState(
messageRes = if (direction == PlayerSeekDirection.Forward) {
Res.string.compose_player_seek_feedback_forward
} else {
Res.string.compose_player_seek_feedback_backward
},
messageArgs = listOf(seconds),
icon = if (direction == PlayerSeekDirection.Forward) {
GestureFeedbackIcon.SeekForward
} else {
GestureFeedbackIcon.SeekBackward
},
),
)
}
internal fun PlayerScreenRuntime.showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) {
val deltaMs = previewPositionMs - baselinePositionMs
val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward
liveGestureFeedback = GestureFeedbackState(
message = formatPlaybackTime(previewPositionMs),
icon = if (direction == PlayerSeekDirection.Forward) {
GestureFeedbackIcon.SeekForward
} else {
GestureFeedbackIcon.SeekBackward
},
secondaryMessageRes = if (deltaMs >= 0L) {
Res.string.compose_player_seek_delta_forward
} else {
Res.string.compose_player_seek_delta_backward
},
secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()),
secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) {
Color(0xFF6EE7A8)
} else {
Color(0xFFFF9A76)
},
)
}
internal fun PlayerScreenRuntime.showBrightnessFeedback(level: Float) {
val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt()
showGestureFeedback(
GestureFeedbackState(
messageRes = Res.string.compose_player_brightness_level,
messageArgs = listOf("$percentage%"),
icon = GestureFeedbackIcon.Brightness,
),
)
}
internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) {
val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
showGestureFeedback(
GestureFeedbackState(
messageRes = if (level.isMuted) {
Res.string.compose_player_muted
} else {
Res.string.compose_player_volume_level
},
messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"),
icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
isDanger = level.isMuted,
),
)
}
internal fun PlayerScreenRuntime.togglePlayback() {
if (playbackSnapshot.isPlaying) {
shouldPlay = false
playerController?.pause()
} else {
if (playbackSnapshot.isEnded) {
playerController?.seekTo(0L)
}
shouldPlay = true
playerController?.play()
}
controlsVisible = true
}
internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) {
playerController?.seekBy(offsetMs)
scheduleProgressSyncAfterSeek()
controlsVisible = true
when {
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs))
}
}
internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) {
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
val currentSeekState = accumulatedSeekState
val nextState = if (currentSeekState?.direction == direction) {
currentSeekState.copy(amountMs = currentSeekState.amountMs + PlayerDoubleTapSeekStepMs)
} else {
PlayerAccumulatedSeekState(
direction = direction,
baselinePositionMs = currentPositionMs,
amountMs = PlayerDoubleTapSeekStepMs,
)
}
accumulatedSeekState = nextState
val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L }
val targetPositionMs = when (direction) {
PlayerSeekDirection.Backward -> {
(nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L)
}
PlayerSeekDirection.Forward -> {
val unclamped = nextState.baselinePositionMs + nextState.amountMs
maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped
}
}
playerController?.seekTo(targetPositionMs)
scheduleProgressSyncAfterSeek()
showSeekFeedback(direction, nextState.amountMs)
accumulatedSeekResetJob?.cancel()
accumulatedSeekResetJob = scope.launch {
delay(PlayerDoubleTapSeekResetDelayMs)
accumulatedSeekState = null
}
}
internal fun PlayerScreenRuntime.cycleResizeMode() {
val nextMode = resizeMode.next()
resizeMode = nextMode
lastSyncedSettingsResizeMode = nextMode
PlayerSettingsRepository.setResizeMode(nextMode)
showGestureMessage(
when (nextMode) {
PlayerResizeMode.Fit -> resizeModeFitLabel
PlayerResizeMode.Fill -> resizeModeFillLabel
PlayerResizeMode.Zoom -> resizeModeZoomLabel
},
)
controlsVisible = true
}
internal fun PlayerScreenRuntime.cyclePlaybackSpeed() {
val speeds = listOf(1f, 1.25f, 1.5f, 2f)
val current = playbackSnapshot.playbackSpeed
val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first()
playerController?.setPlaybackSpeed(next)
showGestureMessage(formatPlaybackSpeedLabel(next))
controlsVisible = true
}
internal fun PlayerScreenRuntime.activateHoldToSpeed() {
if (!playerSettingsUiState.holdToSpeedEnabled) return
val controller = playerController ?: return
if (speedBoostRestoreSpeed != null) return
val targetSpeed = playerSettingsUiState.holdToSpeedValue
val currentSpeed = playbackSnapshot.playbackSpeed
if (abs(currentSpeed - targetSpeed) < 0.01f) return
isHoldToSpeedGestureActive = true
speedBoostRestoreSpeed = currentSpeed
controller.setPlaybackSpeed(targetSpeed)
liveGestureFeedback = GestureFeedbackState(
message = formatPlaybackSpeedLabel(targetSpeed),
icon = GestureFeedbackIcon.Speed,
)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
internal fun PlayerScreenRuntime.deactivateHoldToSpeed() {
isHoldToSpeedGestureActive = false
val restoreSpeed = speedBoostRestoreSpeed ?: return
playerController?.setPlaybackSpeed(restoreSpeed)
speedBoostRestoreSpeed = null
liveGestureFeedback = null
}
@Composable
internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfaceGestureCallbacks {
val onSurfaceTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
val centerStart = layoutSize.width * PlayerLeftGestureBoundary
val centerEnd = layoutSize.width * PlayerRightGestureBoundary
if (controlsVisible && offset.x in centerStart..centerEnd) {
controlsVisible = false
} else {
controlsVisible = !controlsVisible
}
}
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
if (playerControlsLocked) {
revealLockedOverlay()
return@rememberUpdatedState
}
if (!playerSettingsUiState.touchGesturesEnabled) {
controlsVisible = !controlsVisible
return@rememberUpdatedState
}
when {
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Backward)
}
offset.x > layoutSize.width * PlayerRightGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Forward)
}
else -> controlsVisible = !controlsVisible
}
}
return PlayerSurfaceGestureCallbacks(
onSurfaceTap = onSurfaceTap,
onSurfaceDoubleTap = onSurfaceDoubleTap,
activateHoldToSpeed = rememberUpdatedState(::activateHoldToSpeed),
deactivateHoldToSpeed = rememberUpdatedState(::deactivateHoldToSpeed),
showHorizontalSeekPreview = rememberUpdatedState(::showHorizontalSeekPreview),
showBrightnessFeedback = rememberUpdatedState(::showBrightnessFeedback),
showVolumeFeedback = rememberUpdatedState(::showVolumeFeedback),
clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback),
revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay),
isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive),
touchGesturesEnabled = rememberUpdatedState(playerSettingsUiState.touchGesturesEnabled),
playerControlsLocked = rememberUpdatedState(playerControlsLocked),
currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)),
currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs),
commitHorizontalSeek = rememberUpdatedState { targetPositionMs: Long ->
playerController?.seekTo(targetPositionMs)
scheduleProgressSyncAfterSeek()
},
)
}

View file

@ -0,0 +1,232 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal val PlayerScreenRuntime.activePlaybackIdentity: String
get() = activeTorrentInfoHash
?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" }
?: activeSourceUrl
internal val PlayerScreenRuntime.playbackSession: WatchProgressPlaybackSession
get() = WatchProgressPlaybackSession(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
fallbackVideoId = activeVideoId,
),
title = title,
logo = logo,
poster = poster,
background = background,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
episodeThumbnail = activeEpisodeThumbnail,
providerName = activeProviderName,
providerAddonId = activeProviderAddonId,
lastStreamTitle = activeStreamTitle,
lastStreamSubtitle = activeStreamSubtitle,
pauseDescription = pauseDescription,
lastSourceUrl = activeSourceUrl,
)
internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() {
val identity = activePlaybackIdentity
if (lastResetPlaybackIdentity != identity) {
lastResetPlaybackIdentity = identity
shouldPlay = true
initialLoadCompleted = false
speedBoostRestoreSpeed = null
isHoldToSpeedGestureActive = false
initialSeekApplied = activeInitialPositionMs <= 0L &&
(activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f)
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
autoFetchedAddonSubtitlesForKey = null
trackPreferenceRestoreApplied = false
preferredAudioSelectionApplied = false
preferredSubtitleSelectionApplied = false
}
val videoIdentity = "$identity:$activeVideoId:$activeSeasonNumber:$activeEpisodeNumber"
if (lastResetVideoIdentity != videoIdentity) {
lastResetVideoIdentity = videoIdentity
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration = 0L
pendingScrobbleStartAfterSeek = false
hasSentCompletionScrobbleForCurrentItem = false
currentTraktScrobbleItem = null
}
}
internal fun PlayerScreenRuntime.currentPlaybackProgressPercent(
snapshot: PlayerPlaybackSnapshot = playbackSnapshot,
): Float {
val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f
return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f)
.coerceIn(0f, 100f)
}
internal data class TraktScrobbleItemInputs(
val contentType: String,
val parentMetaId: String,
val videoId: String?,
val title: String,
val seasonNumber: Int?,
val episodeNumber: Int?,
val episodeTitle: String?,
)
internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs(
contentType = contentType ?: parentMetaType,
parentMetaId = parentMetaId,
videoId = activeVideoId,
title = title,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
)
private suspend fun TraktScrobbleItemInputs.buildItem() =
TraktScrobbleRepository.buildItem(
contentType = contentType,
parentMetaId = parentMetaId,
videoId = videoId,
title = title,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
)
internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() =
snapshotTraktScrobbleItemInputs().buildItem()
internal fun PlayerScreenRuntime.emitTraktScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
val requestGeneration = scrobbleStartRequestGeneration + 1L
scrobbleStartRequestGeneration = requestGeneration
scope.launch {
val item = currentTraktScrobbleItem()
if (item == null) {
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
return@launch
}
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
item = item,
progressPercent = currentPlaybackProgressPercent(),
)
}
}
internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) {
val provided = progressPercent
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
val itemSnapshot = currentTraktScrobbleItem
val inputsSnapshot = snapshotTraktScrobbleItemInputs()
scope.launch(NonCancellable) {
val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
item = item,
progressPercent = percent,
)
}
currentTraktScrobbleItem = null
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration += 1L
}
internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() {
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
return
}
if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) {
hasSentCompletionScrobbleForCurrentItem = true
emitTraktScrobbleStop(progressPercent)
}
}
internal fun PlayerScreenRuntime.tryShowParentalGuide() {
if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) {
playbackStartedForParentalGuide = true
controlsVisible = true
showParentalGuide = true
parentalGuideHasShown = true
}
}
internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? {
val candidates = listOf(parentMetaId, activeVideoId)
candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it }
val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null
return TmdbService.tmdbToImdb(
tmdbId = tmdbId,
mediaType = contentType ?: parentMetaType,
)
}
internal fun PlayerScreenRuntime.flushWatchProgress() {
emitStopScrobbleForCurrentProgress()
WatchProgressRepository.flushPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
)
}
internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() {
val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
seekProgressSyncJob?.cancel()
seekProgressSyncJob = scope.launch {
delay(PlayerSeekProgressSyncDebounceMs)
WatchProgressRepository.upsertPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
)
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (shouldRestartScrobbleNow) {
pendingScrobbleStartAfterSeek = true
}
}
}
}
internal fun PlayerScreenRuntime.persistPlaybackProgressTick() {
val now = WatchProgressClock.nowEpochMs()
if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) return
lastProgressPersistEpochMs = now
WatchProgressRepository.upsertPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
syncRemote = false,
)
}

View file

@ -0,0 +1,486 @@
package com.nuvio.app.features.player
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.p2p.P2pSettingsRepository
import com.nuvio.app.features.p2p.P2pStreamingEngine
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import kotlinx.coroutines.launch
internal fun PlayerScreenRuntime.resolveDebridForPlayer(
stream: StreamItem,
season: Int?,
episode: Int?,
onResolved: (StreamItem) -> Unit,
onStale: () -> Unit,
): Boolean {
if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false
scope.launch {
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = season,
episode = episode,
)
when (resolved) {
is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
onStale()
}
}
}
}
return true
}
internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?): String =
"torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}"
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:${p2pFileIdx ?: -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()
}
activeTorrentInfoHash = null
activeTorrentFileIdx = null
activeTorrentFilename = null
activeTorrentTrackers = emptyList()
p2pResolvedSourceUrl = null
}
internal fun PlayerScreenRuntime.saveP2pStreamForReuse(
stream: StreamItem,
videoId: String?,
season: Int?,
episode: Int?,
) {
if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return
val infoHash = stream.p2pInfoHash ?: return
val cacheKey = StreamLinkCacheRepository.contentKey(
type = contentType ?: parentMetaType,
videoId = videoId,
parentMetaId = parentMetaId,
season = season,
episode = episode,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = "",
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = emptyMap(),
responseHeaders = emptyMap(),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
infoHash = infoHash,
fileIdx = stream.p2pFileIdx,
sources = stream.sources,
bingeGroup = stream.behaviorHints.bingeGroup,
)
}
internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) {
val infoHash = stream.p2pInfoHash ?: return
if (!P2pSettingsRepository.isVisible) return
if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false)
return
}
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
flushWatchProgress()
stopActiveP2pStream()
saveP2pStreamForReuse(
stream = stream,
videoId = activeVideoId,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentTrackers = stream.p2pTrackers
activeSourceIdentityKey = stream.playerSourceIdentityKey()
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
activeProviderAddonId = stream.addonId
currentStreamBingeGroup = stream.behaviorHints.bingeGroup
activeInitialPositionMs = currentPositionMs
activeInitialProgressFraction = null
showSourcesPanel = false
controlsVisible = true
}
internal fun PlayerScreenRuntime.switchToP2pEpisodeStream(
stream: StreamItem,
episode: MetaVideo,
isAutoPlay: Boolean = false,
) {
val infoHash = stream.p2pInfoHash ?: return
if (!P2pSettingsRepository.isVisible) return
if (!P2pSettingsRepository.uiState.value.p2pEnabled) {
pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay)
return
}
resetEpisodePanelAndNextEpisodeState()
flushWatchProgress()
stopActiveP2pStream()
val epVideoId = episode.id
val resume = resolveEpisodeResume(epVideoId, episode)
saveP2pStreamForReuse(
stream = stream,
videoId = epVideoId,
season = episode.season,
episode = episode.episode,
)
activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx)
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeTorrentInfoHash = infoHash
activeTorrentFileIdx = stream.p2pFileIdx
activeTorrentFilename = stream.behaviorHints.filename
activeTorrentTrackers = stream.p2pTrackers
applyEpisodeStreamMetadata(stream, episode, resume)
}
internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) {
if (
resolveDebridForPlayer(
stream = stream,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
onResolved = { switchToSource(it) },
onStale = {
val vid = activeVideoId
if (vid != null) {
PlayerStreamsRepository.loadSources(
type = contentType ?: parentMetaType,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
forceRefresh = true,
)
}
},
)
) return
if (isP2pStream(stream)) {
switchToP2pSourceStream(stream)
return
}
val url = stream.playableDirectUrl ?: return
val sourceIdentityKey = stream.playerSourceIdentityKey()
if (url == activeSourceUrl) {
activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey
return
}
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
flushWatchProgress()
stopActiveP2pStream()
val currentVideoId = activeVideoId
if (playerSettingsUiState.streamReuseLastLinkEnabled && currentVideoId != null) {
saveDirectStreamForReuse(stream, url, currentVideoId, activeSeasonNumber, activeEpisodeNumber)
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
activeSourceIdentityKey = sourceIdentityKey
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
activeProviderAddonId = stream.addonId
currentStreamBingeGroup = stream.behaviorHints.bingeGroup
activeInitialPositionMs = currentPositionMs
activeInitialProgressFraction = null
showSourcesPanel = false
controlsVisible = true
}
internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
if (
resolveDebridForPlayer(
stream = stream,
season = episode.season,
episode = episode.episode,
onResolved = { resolvedStream -> switchToEpisodeStream(resolvedStream, episode) },
onStale = {
PlayerStreamsRepository.loadEpisodeStreams(
type = contentType ?: parentMetaType,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
forceRefresh = true,
)
},
)
) return
if (isP2pStream(stream)) {
switchToP2pEpisodeStream(stream, episode)
return
}
val url = stream.playableDirectUrl ?: return
resetEpisodePanelAndNextEpisodeState()
flushWatchProgress()
stopActiveP2pStream()
val epVideoId = episode.id
val resume = resolveEpisodeResume(epVideoId, episode)
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
saveDirectStreamForReuse(stream, url, epVideoId, episode.season, episode.episode)
}
activeSourceUrl = url
activeSourceAudioUrl = null
activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request)
activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response)
activeStreamType = stream.streamType
applyEpisodeStreamMetadata(stream, episode, resume)
}
internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) {
val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return
resetEpisodePanelAndNextEpisodeState()
flushWatchProgress()
stopActiveP2pStream()
val fallbackVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId
val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId)
?.takeIf { !it.isCompleted }
val epResumeFraction = epEntry?.progressPercent
?.takeIf { it > 0f }
?.let { (it / 100f).coerceIn(0f, 1f) }
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
activeSourceUrl = localFileUri
activeSourceAudioUrl = null
activeSourceHeaders = emptyMap()
activeSourceResponseHeaders = emptyMap()
activeStreamType = null
activeSourceIdentityKey = null
activeStreamTitle = downloadItem.streamTitle.ifBlank {
episode.title.ifBlank { title }
}
activeStreamSubtitle = downloadItem.streamSubtitle
activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel }
activeProviderAddonId = downloadItem.providerAddonId
currentStreamBingeGroup = null
activeSeasonNumber = episode.season
activeEpisodeNumber = episode.episode
activeEpisodeTitle = episode.title
activeEpisodeThumbnail = episode.thumbnail
activeVideoId = resolvedVideoId
activeInitialPositionMs = epResumePositionMs
activeInitialProgressFraction = epResumeFraction
controlsVisible = true
}
internal fun PlayerScreenRuntime.playNextEpisode() {
scope.launchPlayerNextEpisodeAutoPlay(
previousJob = nextEpisodeAutoPlayJob,
nextEpisodeInfo = nextEpisodeInfo,
allEpisodes = playerMetaVideos,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
contentType = contentType,
settings = playerSettingsUiState,
currentStreamBingeGroup = currentStreamBingeGroup,
onDownloadedEpisodeSelected = { item, episode -> switchToDownloadedEpisode(item, episode) },
onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) },
onManualSelectionRequired = { nextVideo ->
episodeStreamsPanelState = EpisodeStreamsPanelState(
showStreams = true,
selectedEpisode = nextVideo,
)
showEpisodesPanel = true
},
onSearchingChanged = { nextEpisodeAutoPlaySearching = it },
onSourceNameChanged = { nextEpisodeAutoPlaySourceName = it },
onCountdownChanged = { nextEpisodeAutoPlayCountdown = it },
onNextEpisodeCardVisibleChanged = { showNextEpisodeCard = it },
)?.let { job ->
nextEpisodeAutoPlayJob = job
}
}
internal fun PlayerScreenRuntime.openSourcesPanel() {
val vid = activeVideoId ?: return
PlayerStreamsRepository.loadSources(
type = contentType ?: parentMetaType,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
showSourcesPanel = true
showEpisodesPanel = false
controlsVisible = false
}
internal fun PlayerScreenRuntime.openEpisodesPanel() {
if (playerMetaVideos.isEmpty()) {
scope.launch {
playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList()
}
}
showEpisodesPanel = true
showSourcesPanel = false
controlsVisible = false
}
private data class EpisodeResume(val positionMs: Long, val fraction: Float?)
private fun PlayerScreenRuntime.resetEpisodePanelAndNextEpisodeState() {
showNextEpisodeCard = false
showSourcesPanel = false
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
nextEpisodeAutoPlayJob?.cancel()
nextEpisodeAutoPlaySearching = false
nextEpisodeAutoPlaySourceName = null
nextEpisodeAutoPlayCountdown = null
PlayerStreamsRepository.clearEpisodeStreams()
}
private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: MetaVideo): EpisodeResume {
val epResumeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = epVideoId,
)
val epEntry = WatchProgressRepository.progressForVideo(
epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
)?.takeIf { !it.isCompleted }
val epResumeFraction = epEntry?.progressPercent
?.takeIf { it > 0f }
?.let { (it / 100f).coerceIn(0f, 1f) }
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
return EpisodeResume(positionMs = epResumePositionMs, fraction = epResumeFraction)
}
private fun PlayerScreenRuntime.applyEpisodeStreamMetadata(
stream: StreamItem,
episode: MetaVideo,
resume: EpisodeResume,
) {
activeSourceIdentityKey = stream.playerSourceIdentityKey()
activeStreamTitle = stream.streamLabel
activeStreamSubtitle = stream.streamSubtitle
activeProviderName = stream.addonName
activeProviderAddonId = stream.addonId
currentStreamBingeGroup = stream.behaviorHints.bingeGroup
activeSeasonNumber = episode.season
activeEpisodeNumber = episode.episode
activeEpisodeTitle = episode.title
activeEpisodeThumbnail = episode.thumbnail
activeVideoId = episode.id
activeInitialPositionMs = resume.positionMs
activeInitialProgressFraction = resume.fraction
controlsVisible = true
}
private fun PlayerScreenRuntime.saveDirectStreamForReuse(
stream: StreamItem,
url: String,
videoId: String,
season: Int?,
episode: Int?,
) {
val cacheKey = StreamLinkCacheRepository.contentKey(
type = contentType ?: parentMetaType,
videoId = videoId,
parentMetaId = parentMetaId,
season = season,
episode = episode,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = url,
streamName = stream.streamLabel,
addonName = stream.addonName,
addonId = stream.addonId,
requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
filename = stream.behaviorHints.filename,
videoSize = stream.behaviorHints.videoSize,
bingeGroup = stream.behaviorHints.bingeGroup,
streamType = stream.streamType,
)
}

View file

@ -0,0 +1,199 @@
package com.nuvio.app.features.player
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.addons.AddonsUiState
import com.nuvio.app.features.details.MetaDetailsUiState
import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.p2p.P2pSettingsUiState
import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.player.skip.NextEpisodeInfo
import com.nuvio.app.features.player.skip.SkipInterval
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.trakt.TraktScrobbleItem
import com.nuvio.app.features.watched.WatchedUiState
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
internal class PlayerScreenRuntime(
args: PlayerScreenArgs,
) {
var args by mutableStateOf(args)
val title: String get() = args.title
val sourceUrl: String get() = args.sourceUrl
val sourceAudioUrl: String? get() = args.sourceAudioUrl
val sourceHeaders: Map<String, String> get() = args.sourceHeaders
val sourceResponseHeaders: Map<String, String> get() = args.sourceResponseHeaders
val streamType: String? get() = args.streamType
val providerName: String get() = args.providerName
val streamTitle: String get() = args.streamTitle
val streamSubtitle: String? get() = args.streamSubtitle
val initialBingeGroup: String? get() = args.initialBingeGroup
val pauseDescription: String? get() = args.pauseDescription
val logo: String? get() = args.logo
val poster: String? get() = args.poster
val background: String? get() = args.background
val seasonNumber: Int? get() = args.seasonNumber
val episodeNumber: Int? get() = args.episodeNumber
val episodeTitle: String? get() = args.episodeTitle
val episodeThumbnail: String? get() = args.episodeThumbnail
val contentType: String? get() = args.contentType
val videoId: String? get() = args.videoId
val parentMetaId: String get() = args.parentMetaId
val parentMetaType: String get() = args.parentMetaType
val providerAddonId: String? get() = args.providerAddonId
val torrentInfoHash: String? get() = args.torrentInfoHash
val torrentFileIdx: Int? get() = args.torrentFileIdx
val torrentFilename: String? get() = args.torrentFilename
val torrentTrackers: List<String> get() = args.torrentTrackers
val initialPositionMs: Long get() = args.initialPositionMs
val initialProgressFraction: Float? get() = args.initialProgressFraction
val isSeries: Boolean get() = parentMetaType == "series"
lateinit var scope: CoroutineScope
lateinit var hapticFeedback: HapticFeedback
var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState()
var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState()
var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
var watchedUiState: WatchedUiState = WatchedUiState()
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
var sourceStreamsState: StreamsUiState = StreamsUiState()
var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
var addonsUiState: AddonsUiState = AddonsUiState()
var addonSubtitles: List<AddonSubtitle> = emptyList()
var isLoadingAddonSubtitles: Boolean = false
var horizontalSafePadding: Dp = 0.dp
var metrics: PlayerLayoutMetrics = PlayerLayoutMetrics.fromWidth(0.dp)
var sliderEdgePadding: Dp = 0.dp
var overlayBottomPadding: Dp = 0.dp
var sideGestureSystemEdgeExclusionPx: Float = 0f
var resizeModeFitLabel: String = ""
var resizeModeFillLabel: String = ""
var resizeModeZoomLabel: String = ""
var downloadedLabel: String = ""
var airsPrefix: String = ""
var tbaLabel: String = ""
var genericUnknownLabel: String = ""
var parentalGuideLabels: ParentalGuideLabels = ParentalGuideLabels("", "", "", "", "", "", "", "")
var gestureController: PlayerGestureController? = null
var controlsVisible by mutableStateOf(true)
var playerControlsLocked by mutableStateOf(false)
var activeSourceUrl by mutableStateOf(sourceUrl)
var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl)
var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders))
var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders))
var activeStreamType by mutableStateOf(streamType)
var activeTorrentInfoHash by mutableStateOf(torrentInfoHash)
var activeTorrentFileIdx by mutableStateOf(torrentFileIdx)
var activeTorrentFilename by mutableStateOf(torrentFilename)
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)
var activeProviderAddonId by mutableStateOf(providerAddonId)
var currentStreamBingeGroup by mutableStateOf(initialBingeGroup)
var activeSeasonNumber by mutableStateOf(seasonNumber)
var activeEpisodeNumber by mutableStateOf(episodeNumber)
var activeEpisodeTitle by mutableStateOf(episodeTitle)
var activeEpisodeThumbnail by mutableStateOf(episodeThumbnail)
var activeVideoId by mutableStateOf(videoId)
var activeInitialPositionMs by mutableStateOf(initialPositionMs)
var activeInitialProgressFraction by mutableStateOf(initialProgressFraction)
var shouldPlay by mutableStateOf(true)
var resizeMode by mutableStateOf(playerSettingsUiState.resizeMode)
var layoutSize by mutableStateOf(IntSize.Zero)
var playbackSnapshot by mutableStateOf(PlayerPlaybackSnapshot())
var playerController by mutableStateOf<PlayerEngineController?>(null)
var playerControllerSourceUrl by mutableStateOf<String?>(null)
var errorMessage by mutableStateOf<String?>(null)
var isScrubbingTimeline by mutableStateOf(false)
var scrubbingPositionMs by mutableStateOf<Long?>(null)
var pausedOverlayVisible by mutableStateOf(false)
var gestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var liveGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var renderedGestureFeedback by mutableStateOf<GestureFeedbackState?>(null)
var lockedOverlayVisible by mutableStateOf(false)
var gestureMessageJob by mutableStateOf<Job?>(null)
var accumulatedSeekResetJob by mutableStateOf<Job?>(null)
var seekProgressSyncJob by mutableStateOf<Job?>(null)
var accumulatedSeekState by mutableStateOf<PlayerAccumulatedSeekState?>(null)
var initialLoadCompleted by mutableStateOf(false)
var speedBoostRestoreSpeed by mutableStateOf<Float?>(null)
var isHoldToSpeedGestureActive by mutableStateOf(false)
var initialSeekApplied by mutableStateOf(
initialPositionMs <= 0L && ((initialProgressFraction ?: 0f) <= 0f),
)
var lastProgressPersistEpochMs by mutableStateOf(0L)
var previousIsPlaying by mutableStateOf(false)
var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false)
var scrobbleStartRequestGeneration by mutableStateOf(0L)
var pendingScrobbleStartAfterSeek by mutableStateOf(false)
var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false)
var currentTraktScrobbleItem by mutableStateOf<TraktScrobbleItem?>(null)
var showSourcesPanel by mutableStateOf(false)
var showEpisodesPanel by mutableStateOf(false)
var showSubmitIntroModal by mutableStateOf(false)
var submitIntroSegmentType by mutableStateOf("intro")
var submitIntroStartTimeStr by mutableStateOf("00:00")
var submitIntroEndTimeStr by mutableStateOf("00:00")
var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState())
var playerMetaVideos by mutableStateOf<List<MetaVideo>>(emptyList())
var skipIntervals by mutableStateOf<List<SkipInterval>>(emptyList())
var activeSkipInterval by mutableStateOf<SkipInterval?>(null)
var skipIntervalDismissed by mutableStateOf(false)
var parentalWarnings by mutableStateOf<List<ParentalWarning>>(emptyList())
var showParentalGuide by mutableStateOf(false)
var parentalGuideHasShown by mutableStateOf(false)
var playbackStartedForParentalGuide by mutableStateOf(false)
var nextEpisodeInfo by mutableStateOf<NextEpisodeInfo?>(null)
var showNextEpisodeCard by mutableStateOf(false)
var nextEpisodeAutoPlaySearching by mutableStateOf(false)
var nextEpisodeAutoPlaySourceName by mutableStateOf<String?>(null)
var nextEpisodeAutoPlayCountdown by mutableStateOf<Int?>(null)
var nextEpisodeAutoPlayJob by mutableStateOf<Job?>(null)
var pendingP2pSwitch by mutableStateOf<PendingPlayerP2pSwitch?>(null)
var credentialRefreshJob by mutableStateOf<Job?>(null)
var credentialRefreshAttemptedSourceUrl by mutableStateOf<String?>(null)
var showAudioModal by mutableStateOf(false)
var showSubtitleModal by mutableStateOf(false)
var showVideoSettingsModal by mutableStateOf(false)
var audioTracks by mutableStateOf<List<AudioTrack>>(emptyList())
var subtitleTracks by mutableStateOf<List<SubtitleTrack>>(emptyList())
var selectedAudioIndex by mutableStateOf(-1)
var selectedSubtitleIndex by mutableStateOf(-1)
var selectedAddonSubtitleId by mutableStateOf<String?>(null)
var useCustomSubtitles by mutableStateOf(false)
var preferredAudioSelectionApplied by mutableStateOf(false)
var preferredSubtitleSelectionApplied by mutableStateOf(false)
var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn)
var autoFetchedAddonSubtitlesForKey by mutableStateOf<String?>(null)
var trackPreferenceRestoreApplied by mutableStateOf(false)
var subtitleDelayMs by mutableStateOf(0)
var subtitleAutoSyncState by mutableStateOf(SubtitleAutoSyncUiState())
var lastSyncedSettingsResizeMode: PlayerResizeMode? = null
var lastResetPlaybackIdentity: String? = null
var lastResetVideoIdentity: String? = null
}

View file

@ -0,0 +1,65 @@
package com.nuvio.app.features.player
import com.nuvio.app.core.i18n.localizedNoSubtitleLinesFound
import com.nuvio.app.core.i18n.localizedSubtitleLinesLoadError
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import kotlinx.coroutines.launch
internal fun PlayerScreenRuntime.fetchAddonSubtitlesForActiveItem() {
val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return
val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return
SubtitleRepository.fetchAddonSubtitles(type, videoId)
}
internal fun PlayerScreenRuntime.setSubtitleDelay(delayMs: Int) {
val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
subtitleDelayMs = clamped
PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped)
playerController?.setSubtitleDelayMs(clamped)
}
internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false) {
val subtitle = selectedAddonSubtitle ?: return
if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return
subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null)
scope.launch {
val result = runCatching {
val body = httpGetTextWithHeaders(
url = subtitle.url,
headers = sanitizePlaybackHeaders(activeSourceHeaders),
)
PlayerSubtitleCueParser.parse(body, subtitle.url)
}
result.fold(
onSuccess = { cues ->
subtitleAutoSyncState = subtitleAutoSyncState.copy(
cues = cues,
isLoading = false,
errorMessage = if (cues.isEmpty()) localizedNoSubtitleLinesFound() else null,
)
},
onFailure = { error ->
subtitleAutoSyncState = subtitleAutoSyncState.copy(
isLoading = false,
errorMessage = error.message ?: localizedSubtitleLinesLoadError(),
)
},
)
}
}
internal fun PlayerScreenRuntime.captureSubtitleAutoSyncTime() {
subtitleAutoSyncState = subtitleAutoSyncState.copy(
capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L),
errorMessage = null,
)
loadSubtitleAutoSyncCues()
}
internal fun PlayerScreenRuntime.applySubtitleAutoSyncCue(cue: SubtitleSyncCue) {
val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return
val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS)
.toInt()
.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
setSubtitleDelay(newDelayMs)
}

View file

@ -0,0 +1,217 @@
package com.nuvio.app.features.player
internal val PlayerScreenRuntime.subtitleStyle: SubtitleStyleState
get() = playerSettingsUiState.subtitleStyle
internal val PlayerScreenRuntime.activeAddonSubtitleType: String
get() = contentType ?: parentMetaType
internal val PlayerScreenRuntime.addonSubtitleFetchKey: String?
get() = buildAddonSubtitleFetchKey(
addons = addonsUiState.addons,
type = activeAddonSubtitleType,
videoId = activeVideoId,
)
internal val PlayerScreenRuntime.visibleAddonSubtitles: List<AddonSubtitle>
get() = filterAddonSubtitlesForSettings(
subtitles = addonSubtitles,
settings = playerSettingsUiState,
selectedAddonSubtitleId = selectedAddonSubtitleId,
)
internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle?
get() = addonSubtitles.firstOrNull { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
internal fun PlayerScreenRuntime.updateTrackPreference(
update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference,
) {
if (parentMetaId.isBlank()) return
val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
}
internal fun PlayerScreenRuntime.persistAudioPreference(track: AudioTrack?) {
updateTrackPreference { current ->
current.copy(
audioLanguage = track?.language,
audioName = track?.label,
audioTrackId = track?.id,
)
}
}
internal fun PlayerScreenRuntime.persistInternalSubtitlePreference(track: SubtitleTrack?) {
updateTrackPreference { current ->
current.copy(
subtitleType = if (track == null) {
PersistedSubtitleSelectionType.DISABLED
} else {
PersistedSubtitleSelectionType.INTERNAL
},
subtitleLanguage = track?.language,
subtitleName = track?.label,
subtitleTrackId = track?.id,
addonSubtitleId = null,
addonSubtitleUrl = null,
addonSubtitleAddonName = null,
)
}
}
internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
updateTrackPreference { current ->
current.copy(
subtitleType = PersistedSubtitleSelectionType.ADDON,
subtitleLanguage = subtitle.language,
subtitleName = subtitle.display,
subtitleTrackId = null,
addonSubtitleId = subtitle.id,
addonSubtitleUrl = subtitle.url,
addonSubtitleAddonName = subtitle.addonName,
)
}
}
internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() {
if (trackPreferenceRestoreApplied) return
val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
if (preference == null) {
trackPreferenceRestoreApplied = true
return
}
if (
audioTracks.isNotEmpty() &&
(!preference.audioTrackId.isNullOrBlank() ||
!preference.audioLanguage.isNullOrBlank() ||
!preference.audioName.isNullOrBlank())
) {
val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
playerController?.selectAudioTrack(restoredAudioIndex)
selectedAudioIndex = restoredAudioIndex
}
preferredAudioSelectionApplied = true
}
when (preference.subtitleType) {
PersistedSubtitleSelectionType.DISABLED -> {
playerController?.selectSubtitleTrack(-1)
selectedSubtitleIndex = -1
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
PersistedSubtitleSelectionType.INTERNAL -> {
if (subtitleTracks.isNotEmpty()) {
val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
if (restoredSubtitleIndex >= 0) {
if (useCustomSubtitles) {
playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
} else {
playerController?.selectSubtitleTrack(restoredSubtitleIndex)
}
selectedSubtitleIndex = restoredSubtitleIndex
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
}
}
PersistedSubtitleSelectionType.ADDON -> {
val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
if (url != null) {
selectedAddonSubtitleId = preference.addonSubtitleId ?: url
selectedSubtitleIndex = -1
useCustomSubtitles = true
playerController?.setSubtitleUri(url)
preferredSubtitleSelectionApplied = true
}
}
}
trackPreferenceRestoreApplied = true
}
internal fun PlayerScreenRuntime.refreshTracks() {
val ctrl = playerController ?: return
audioTracks = ctrl.getAudioTracks()
subtitleTracks = ctrl.getSubtitleTracks()
val selectedAudio = audioTracks.firstOrNull { it.isSelected }
if (selectedAudio != null) selectedAudioIndex = selectedAudio.index
val selectedSub = subtitleTracks.firstOrNull { it.isSelected }
if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index
restorePersistedTrackPreferenceIfNeeded()
if (!preferredAudioSelectionApplied) {
val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
)
if (preferredAudioTargets.isEmpty()) {
preferredAudioSelectionApplied = true
} else if (audioTracks.isNotEmpty()) {
val preferredAudioIndex = findPreferredTrackIndex(
tracks = audioTracks,
targets = preferredAudioTargets,
language = { track -> track.language },
)
if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) {
playerController?.selectAudioTrack(preferredAudioIndex)
selectedAudioIndex = preferredAudioIndex
}
preferredAudioSelectionApplied = true
}
}
if (!preferredSubtitleSelectionApplied) {
val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
SubtitleLanguageOption.FORCED
} else {
playerSettingsUiState.preferredSubtitleLanguage
},
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
)
if (preferredSubtitleTargets.isEmpty()) {
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
playerController?.selectSubtitleTrack(-1)
}
selectedSubtitleIndex = -1
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
} else if (subtitleTracks.isNotEmpty()) {
val preferredSubtitleIndex = findPreferredSubtitleTrackIndex(
tracks = subtitleTracks,
targets = preferredSubtitleTargets,
)
if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) {
playerController?.selectSubtitleTrack(preferredSubtitleIndex)
selectedSubtitleIndex = preferredSubtitleIndex
selectedAddonSubtitleId = null
useCustomSubtitles = false
} else if (
preferredSubtitleIndex < 0 &&
(subtitleStyle.useForcedSubtitles ||
normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) ==
SubtitleLanguageOption.FORCED)
) {
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
playerController?.selectSubtitleTrack(-1)
}
selectedSubtitleIndex = -1
selectedAddonSubtitleId = null
useCustomSubtitles = false
}
preferredSubtitleSelectionApplied = true
}
}
}

View file

@ -0,0 +1,531 @@
package com.nuvio.app.features.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import com.nuvio.app.features.p2p.P2pStreamingState
import com.nuvio.app.features.p2p.formatP2pMegabytes
import com.nuvio.app.features.p2p.formatP2pSpeed
import com.nuvio.app.isIos
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
@Composable
internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
val runtime = this
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
val isP2pPlaybackActive = activeTorrentInfoHash != null
val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming
val p2pPeerInfo = p2pStats?.let { stats ->
org.jetbrains.compose.resources.stringResource(
nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info,
stats.seeds,
stats.peers,
)
}
val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) }
val p2pInitialLoadingMessage = when {
!isP2pPlaybackActive || initialLoadCompleted -> null
p2pStreamingState is P2pStreamingState.Connecting -> {
org.jetbrains.compose.resources.stringResource(
nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers,
)
}
p2pStats != null -> {
if (p2pSettingsUiState.hideTorrentStats) {
null
} else {
org.jetbrains.compose.resources.stringResource(
nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status,
formatP2pMegabytes(p2pStats.preloadedBytes),
p2pPeerInfo.orEmpty(),
p2pDownloadSpeed.orEmpty(),
)
}
}
else -> org.jetbrains.compose.resources.stringResource(
nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine,
)
}
val p2pInitialLoadingProgress = when {
!isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null
else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f)
}
val showP2pRebufferStats = isP2pPlaybackActive &&
initialLoadCompleted &&
playbackSnapshot.isLoading &&
p2pStats != null &&
!p2pSettingsUiState.hideTorrentStats
val p2pRebufferMessage = when {
!showP2pRebufferStats -> null
else -> {
val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L)
.coerceAtLeast(0L)
"${bufferedSeconds}s buffered · ${p2pPeerInfo.orEmpty()} · ${p2pDownloadSpeed.orEmpty()}"
}
}
val p2pRebufferProgress = when {
!showP2pRebufferStats -> null
else -> {
val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f)
.coerceAtLeast(0f)
(bufferedSeconds / 10f).coerceIn(0f, 1f)
}
}
val gestureCallbacks = rememberSurfaceGestureCallbacks()
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { layoutSize = it }
.playerSurfaceTapGestures(
layoutSize = layoutSize,
playerControlsLockedState = gestureCallbacks.playerControlsLocked,
onSurfaceTap = gestureCallbacks.onSurfaceTap,
onSurfaceDoubleTap = gestureCallbacks.onSurfaceDoubleTap,
activateHoldToSpeedState = gestureCallbacks.activateHoldToSpeed,
deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed,
revealLockedOverlayState = gestureCallbacks.revealLockedOverlay,
)
.playerSurfaceDragGestures(
gestureController = gestureController,
layoutSize = layoutSize,
sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx,
playerControlsLockedState = gestureCallbacks.playerControlsLocked,
touchGesturesEnabledState = gestureCallbacks.touchGesturesEnabled,
isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive,
currentPositionMsState = gestureCallbacks.currentPositionMs,
currentDurationMsState = gestureCallbacks.currentDurationMs,
deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed,
showHorizontalSeekPreviewState = gestureCallbacks.showHorizontalSeekPreview,
showBrightnessFeedbackState = gestureCallbacks.showBrightnessFeedback,
showVolumeFeedbackState = gestureCallbacks.showVolumeFeedback,
clearLiveGestureFeedbackState = gestureCallbacks.clearLiveGestureFeedback,
revealLockedOverlayState = gestureCallbacks.revealLockedOverlay,
commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek,
),
) {
val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl
if (playerSurfaceSourceUrl != null) {
PlatformPlayerSurface(
sourceUrl = playerSurfaceSourceUrl,
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
streamType = activeStreamType,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,
resizeMode = resizeMode,
onControllerReady = { controller ->
playerController = controller
playerControllerSourceUrl = activeSourceUrl
},
onSnapshot = { snapshot ->
playbackSnapshot = snapshot
if (!snapshot.isLoading) initialLoadCompleted = true
if (snapshot.isEnded) {
shouldPlay = false
controlsVisible = !playerControlsLocked
}
},
onError = { message ->
if (message != null && tryRefreshCredentialedSourceAfterError(message)) {
return@PlatformPlayerSurface
}
errorMessage = message
if (message != null) {
controlsVisible = !playerControlsLocked
removeFailedStreamFromCache()
}
},
)
}
AnimatedVisibility(
visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked,
enter = fadeIn(animationSpec = tween(durationMillis = 220)),
exit = fadeOut(animationSpec = tween(durationMillis = 180)),
) {
PauseMetadataOverlay(
title = title,
logo = logo,
isEpisode = isEpisode,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
pauseDescription = pauseDescription ?: activeStreamSubtitle,
providerName = activeProviderName,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
)
}
RenderPlayerControls(displayedPositionMs = displayedPositionMs, isEpisode = isEpisode)
RenderPlaybackOverlays(
runtime = runtime,
displayedPositionMs = displayedPositionMs,
currentGestureFeedback = currentGestureFeedback,
p2pInitialLoadingMessage = p2pInitialLoadingMessage,
p2pInitialLoadingProgress = p2pInitialLoadingProgress,
showP2pRebufferStats = showP2pRebufferStats,
p2pRebufferMessage = p2pRebufferMessage,
p2pRebufferProgress = p2pRebufferProgress,
)
RenderPlayerModals(displayedPositionMs = displayedPositionMs)
}
}
@Composable
private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) {
AnimatedVisibility(
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
enter = fadeIn(),
exit = fadeOut(),
) {
PlayerControlsShell(
title = title,
streamTitle = activeStreamTitle,
providerName = activeProviderName,
seasonNumber = activeSeasonNumber,
episodeNumber = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
resizeMode = resizeMode,
isLocked = playerControlsLocked,
showPlaybackControls = controlsVisible,
onLockToggle = {
if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls()
},
onBack = {
flushWatchProgress()
args.onBack()
},
onTogglePlayback = { togglePlayback() },
onSeekBack = { seekBy(-10_000L) },
onSeekForward = { seekBy(10_000L) },
onResizeModeClick = { cycleResizeMode() },
onSpeedClick = { cyclePlaybackSpeed() },
onSubtitleClick = {
refreshTracks()
showSubtitleModal = true
},
onAudioClick = {
refreshTracks()
showAudioModal = true
},
onVideoSettingsClick = if (isIos) {
{
showVideoSettingsModal = true
controlsVisible = true
}
} else {
null
},
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
onOpenInExternalPlayer = args.onOpenInExternalPlayer?.let { openExternal ->
{
val loadedSubtitles = addonSubtitles
.takeIf { it.isNotEmpty() }
?.map { sub ->
SubtitleInput(
url = sub.url,
name = buildString {
if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ")
append(sub.display)
},
lang = sub.language,
)
}
openExternal(
ExternalPlayerPlaybackRequest(
sourceUrl = activeSourceUrl,
title = title,
streamTitle = activeStreamTitle,
sourceHeaders = activeSourceHeaders,
resumePositionMs = playbackSnapshot.positionMs,
subtitles = loadedSubtitles,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
episodeTitle = activeEpisodeTitle,
),
)
}
},
onSubmitIntroClick = if (
isSeries &&
playerSettingsUiState.introSubmitEnabled &&
playerSettingsUiState.introDbApiKey.isNotBlank()
) {
{ showSubmitIntroModal = true }
} else {
null
},
parentalWarnings = parentalWarnings,
showParentalGuide = showParentalGuide,
onParentalGuideAnimationComplete = { showParentalGuide = false },
onScrubChange = { positionMs ->
isScrubbingTimeline = true
scrubbingPositionMs = positionMs
},
onScrubFinished = { positionMs ->
isScrubbingTimeline = false
scrubbingPositionMs = null
playerController?.seekTo(positionMs)
scheduleProgressSyncAfterSeek()
},
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun BoxScope.RenderPlaybackOverlays(
runtime: PlayerScreenRuntime,
displayedPositionMs: Long,
currentGestureFeedback: GestureFeedbackState?,
p2pInitialLoadingMessage: String?,
p2pInitialLoadingProgress: Float?,
showP2pRebufferStats: Boolean,
p2pRebufferMessage: String?,
p2pRebufferProgress: Float?,
) {
runtime.run {
PlayerPlaybackOverlays(
playerControlsLocked = playerControlsLocked,
lockedOverlayVisible = lockedOverlayVisible,
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
horizontalSafePadding = horizontalSafePadding,
onUnlock = { unlockPlayerControls() },
showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
backdropArtwork = background ?: poster,
logo = logo,
title = title,
onBackWithProgress = {
flushWatchProgress()
args.onBack()
},
p2pInitialLoadingMessage = p2pInitialLoadingMessage,
p2pInitialLoadingProgress = p2pInitialLoadingProgress,
showP2pRebufferStats = showP2pRebufferStats,
p2pRebufferMessage = p2pRebufferMessage,
p2pRebufferProgress = p2pRebufferProgress,
currentGestureFeedback = currentGestureFeedback,
renderedGestureFeedback = renderedGestureFeedback,
initialLoadCompleted = initialLoadCompleted,
pausedOverlayVisible = pausedOverlayVisible,
activeSkipInterval = activeSkipInterval,
skipIntervalDismissed = skipIntervalDismissed,
controlsVisible = controlsVisible,
onSkipInterval = { interval ->
playerController?.seekTo((interval.endTime * 1000).toLong())
scheduleProgressSyncAfterSeek()
skipIntervalDismissed = true
},
onDismissSkipInterval = { skipIntervalDismissed = true },
sliderEdgePadding = sliderEdgePadding,
overlayBottomPadding = overlayBottomPadding,
isSeries = isSeries,
nextEpisodeInfo = nextEpisodeInfo,
showNextEpisodeCard = showNextEpisodeCard,
nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching,
nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName,
nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown,
onPlayNextEpisode = {
nextEpisodeAutoPlayJob?.cancel()
playNextEpisode()
},
onDismissNextEpisode = {
nextEpisodeAutoPlayJob?.cancel()
showNextEpisodeCard = false
nextEpisodeAutoPlaySearching = false
nextEpisodeAutoPlaySourceName = null
nextEpisodeAutoPlayCountdown = null
},
errorMessage = errorMessage,
onDismissError = {
flushWatchProgress()
args.onBack()
},
)
}
}
@Composable
private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
PlayerScreenModalHosts(
pendingP2pSwitch = pendingP2pSwitch,
onPendingP2pSwitchChanged = { pendingP2pSwitch = it },
onP2pEpisodeStreamSelected = { stream, episode, isAutoPlay ->
switchToP2pEpisodeStream(stream, episode, isAutoPlay)
},
onP2pSourceStreamSelected = { stream -> switchToP2pSourceStream(stream) },
onNextEpisodeAutoPlaySearchingChanged = { nextEpisodeAutoPlaySearching = it },
onNextEpisodeAutoPlayCountdownChanged = { nextEpisodeAutoPlayCountdown = it },
onNextEpisodeAutoPlaySourceNameChanged = { nextEpisodeAutoPlaySourceName = it },
showAudioModal = showAudioModal,
audioTracks = audioTracks,
selectedAudioIndex = selectedAudioIndex,
onAudioTrackSelected = { index ->
selectedAudioIndex = index
persistAudioPreference(audioTracks.firstOrNull { it.index == index })
playerController?.selectAudioTrack(index)
scope.launch {
kotlinx.coroutines.delay(200)
showAudioModal = false
}
},
onAudioModalDismissed = { showAudioModal = false },
showSubtitleModal = showSubtitleModal,
activeSubtitleTab = activeSubtitleTab,
subtitleTracks = subtitleTracks,
selectedSubtitleIndex = selectedSubtitleIndex,
addonSubtitles = visibleAddonSubtitles,
selectedAddonSubtitleId = selectedAddonSubtitleId,
isLoadingAddonSubtitles = isLoadingAddonSubtitles,
subtitleStyle = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = selectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
onSubtitleTabSelected = { activeSubtitleTab = it },
onBuiltInSubtitleTrackSelected = { index ->
val wasCustom = useCustomSubtitles
selectedSubtitleIndex = index
selectedAddonSubtitleId = null
useCustomSubtitles = false
persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index })
if (wasCustom) {
playerController?.clearExternalSubtitleAndSelect(index)
} else {
playerController?.selectSubtitleTrack(index)
}
},
onAddonSubtitleSelected = { addon ->
selectedAddonSubtitleId = addon.id
selectedSubtitleIndex = -1
useCustomSubtitles = true
persistAddonSubtitlePreference(addon)
playerController?.setSubtitleUri(addon.url)
},
onFetchAddonSubtitles = { fetchAddonSubtitlesForActiveItem() },
onSubtitleStyleChanged = PlayerSettingsRepository::setSubtitleStyle,
onSubtitleDelayChanged = { delayMs -> setSubtitleDelay(delayMs) },
onSubtitleDelayReset = { setSubtitleDelay(0) },
onAutoSyncCapture = { captureSubtitleAutoSyncTime() },
onAutoSyncCueSelected = { cue -> applySubtitleAutoSyncCue(cue) },
onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) },
onSubtitleModalDismissed = { showSubtitleModal = false },
showVideoSettingsModal = showVideoSettingsModal,
playerSettings = playerSettingsUiState,
onVideoSettingsChanged = {
playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value)
},
onVideoSettingsModalDismissed = { showVideoSettingsModal = false },
showSourcesPanel = showSourcesPanel,
sourceStreamsState = sourceStreamsState,
activeSourceUrl = activeSourceUrl,
activeStreamTitle = activeStreamTitle,
onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter,
onSourceStreamSelected = { stream -> switchToSource(stream) },
onReloadSources = {
val vid = activeVideoId
if (vid != null) {
PlayerStreamsRepository.loadSources(
type = contentType ?: parentMetaType,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
forceRefresh = true,
)
}
},
onSourcesPanelDismissed = {
showSourcesPanel = false
controlsVisible = true
},
isSeries = isSeries,
showEpisodesPanel = showEpisodesPanel,
allEpisodes = playerMetaVideos,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
activeSeasonNumber = activeSeasonNumber,
activeEpisodeNumber = activeEpisodeNumber,
watchProgressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
episodeStreamsPanelState = episodeStreamsPanelState,
episodeStreamsRepoState = episodeStreamsRepoState,
onEpisodeSelectedForDownload = { episode ->
selectDownloadedEpisodeForPlayback(
parentMetaId = parentMetaId,
episode = episode,
onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) },
)
},
onEpisodeStreamsRequested = { episode ->
PlayerStreamsRepository.loadEpisodeStreams(
type = contentType ?: parentMetaType,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
)
episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode)
},
onEpisodeStreamFilterSelected = PlayerStreamsRepository::selectEpisodeStreamsFilter,
onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) },
onBackToEpisodes = {
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
},
onReloadEpisodeStreams = {
val episode = episodeStreamsPanelState.selectedEpisode
if (episode != null) {
PlayerStreamsRepository.loadEpisodeStreams(
type = contentType ?: parentMetaType,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
forceRefresh = true,
)
}
},
onEpisodesPanelDismissed = {
showEpisodesPanel = false
episodeStreamsPanelState = EpisodeStreamsPanelState()
PlayerStreamsRepository.clearEpisodeStreams()
controlsVisible = true
},
showSubmitIntroModal = showSubmitIntroModal,
activeVideoId = activeVideoId,
metaUiState = metaUiState,
displayedPositionMs = displayedPositionMs,
submitIntroSegmentType = submitIntroSegmentType,
onSubmitIntroSegmentTypeChanged = { submitIntroSegmentType = it },
submitIntroStartTimeStr = submitIntroStartTimeStr,
onSubmitIntroStartTimeChanged = { submitIntroStartTimeStr = it },
submitIntroEndTimeStr = submitIntroEndTimeStr,
onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it },
onSubmitIntroDismissed = { showSubmitIntroModal = false },
onSubmitIntroSuccess = {
submitIntroStartTimeStr = "00:00"
submitIntroEndTimeStr = "00:00"
submitIntroSegmentType = "intro"
showSubmitIntroModal = false
},
)
}

View file

@ -0,0 +1,59 @@
package com.nuvio.app.features.player
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamItem
internal const val PlaybackProgressPersistIntervalMs = 60_000L
internal const val PlayerDoubleTapSeekStepMs = 10_000L
internal const val PlayerDoubleTapSeekResetDelayMs = 800L
internal const val PlayerLockedOverlayDurationMs = 2_000L
internal const val PlayerLeftGestureBoundary = 0.4f
internal const val PlayerRightGestureBoundary = 0.6f
internal const val PlayerVerticalGestureSensitivity = 0.65f
internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f
internal const val PlayerVerticalGestureMinHeightFraction = 0.06f
internal const val PlayerVerticalGestureDominanceRatio = 1.2f
internal const val PlayerSeekProgressSyncDebounceMs = 700L
internal const val P2pInitialPreloadTargetBytes = 5_242_880L
internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
internal val PlayerSideGestureSystemEdgeExclusion = 72.dp
internal val PlayerSliderOverlayGap = 12.dp
internal val PlayerTimeRowHeight = 36.dp
internal val PlayerActionRowHeight = 50.dp
internal fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) =
metrics.sliderBottomOffset +
metrics.sliderTouchHeight +
PlayerTimeRowHeight +
PlayerActionRowHeight +
PlayerSliderOverlayGap
internal enum class PlayerSideGesture {
Brightness,
Volume,
}
internal enum class PlayerSeekDirection {
Backward,
Forward,
}
internal enum class PlayerGestureMode {
HorizontalSeek,
Brightness,
Volume,
}
internal data class PlayerAccumulatedSeekState(
val direction: PlayerSeekDirection,
val baselinePositionMs: Long,
val amountMs: Long,
)
internal data class PendingPlayerP2pSwitch(
val stream: StreamItem,
val episode: MetaVideo?,
val isAutoPlay: Boolean,
)

View file

@ -36,6 +36,7 @@ data class PlayerSettingsUiState(
val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit,
val holdToSpeedEnabled: Boolean = true,
val holdToSpeedValue: Float = 2f,
val touchGesturesEnabled: Boolean = true,
val externalPlayerEnabled: Boolean = false,
val externalPlayerForwardSubtitles: Boolean = false,
val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(),
@ -73,7 +74,8 @@ data class PlayerSettingsUiState(
val iosToneMappingMode: IosToneMappingMode = IosToneMappingMode.Auto,
val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto,
val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto,
val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.Auto,
val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox,
val iosAudioOutputMode: IosAudioOutputMode = IosAudioOutputMode.Auto,
val iosExtendedDynamicRangeEnabled: Boolean = true,
val iosTargetColorspaceHintEnabled: Boolean = true,
val iosHdrComputePeakEnabled: Boolean = true,
@ -94,6 +96,7 @@ object PlayerSettingsRepository {
private var resizeMode = PlayerResizeMode.Fit
private var holdToSpeedEnabled = true
private var holdToSpeedValue = 2f
private var touchGesturesEnabled = true
private var externalPlayerEnabled = false
private var externalPlayerForwardSubtitles = false
private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId()
@ -131,7 +134,8 @@ object PlayerSettingsRepository {
private var iosToneMappingMode = IosToneMappingMode.Auto
private var iosTargetPrimaries = IosTargetPrimaries.Auto
private var iosTargetTransfer = IosTargetTransfer.Auto
private var iosHardwareDecoderMode = IosHardwareDecoderMode.Auto
private var iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
private var iosAudioOutputMode = IosAudioOutputMode.Auto
private var iosExtendedDynamicRangeEnabled = true
private var iosTargetColorspaceHintEnabled = true
private var iosHdrComputePeakEnabled = true
@ -157,6 +161,7 @@ object PlayerSettingsRepository {
resizeMode = PlayerResizeMode.Fit
holdToSpeedEnabled = true
holdToSpeedValue = 2f
touchGesturesEnabled = true
externalPlayerEnabled = false
externalPlayerForwardSubtitles = false
externalPlayerId = ExternalPlayerPlatform.defaultPlayerId()
@ -194,7 +199,8 @@ object PlayerSettingsRepository {
iosToneMappingMode = IosToneMappingMode.Auto
iosTargetPrimaries = IosTargetPrimaries.Auto
iosTargetTransfer = IosTargetTransfer.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.Auto
iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox
iosAudioOutputMode = IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = true
iosTargetColorspaceHintEnabled = true
iosHdrComputePeakEnabled = true
@ -215,6 +221,7 @@ object PlayerSettingsRepository {
?: PlayerResizeMode.Fit
holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true
holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f
touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true
externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false
externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false
externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId()
@ -317,7 +324,10 @@ object PlayerSettingsRepository {
?: IosTargetTransfer.Auto
iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode()
?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() }
?: IosHardwareDecoderMode.Auto
?: IosHardwareDecoderMode.VideoToolbox
iosAudioOutputMode = PlayerSettingsStorage.loadIosAudioOutputMode()
?.let { runCatching { IosAudioOutputMode.valueOf(it) }.getOrNull() }
?: IosAudioOutputMode.Auto
iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true
iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true
iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true
@ -363,6 +373,14 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveHoldToSpeedValue(normalized)
}
fun setTouchGesturesEnabled(enabled: Boolean) {
ensureLoaded()
if (touchGesturesEnabled == enabled) return
touchGesturesEnabled = enabled
publish()
PlayerSettingsStorage.saveTouchGesturesEnabled(enabled)
}
fun setExternalPlayerEnabled(enabled: Boolean) {
ensureLoaded()
if (enabled && externalPlayerId.isNullOrBlank()) {
@ -716,6 +734,13 @@ object PlayerSettingsRepository {
PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name)
}
fun setIosAudioOutputMode(mode: IosAudioOutputMode) {
ensureLoaded()
iosAudioOutputMode = mode
publish()
PlayerSettingsStorage.saveIosAudioOutputMode(mode.name)
}
fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) {
ensureLoaded()
iosVideoOutputPreset = IosVideoOutputPreset.Custom
@ -815,6 +840,7 @@ object PlayerSettingsRepository {
resizeMode = resizeMode,
holdToSpeedEnabled = holdToSpeedEnabled,
holdToSpeedValue = holdToSpeedValue,
touchGesturesEnabled = touchGesturesEnabled,
externalPlayerEnabled = externalPlayerEnabled,
externalPlayerForwardSubtitles = externalPlayerForwardSubtitles,
externalPlayerId = externalPlayerId,
@ -853,6 +879,7 @@ object PlayerSettingsRepository {
iosTargetPrimaries = iosTargetPrimaries,
iosTargetTransfer = iosTargetTransfer,
iosHardwareDecoderMode = iosHardwareDecoderMode,
iosAudioOutputMode = iosAudioOutputMode,
iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled,
iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled,
iosHdrComputePeakEnabled = iosHdrComputePeakEnabled,

View file

@ -11,6 +11,8 @@ internal expect object PlayerSettingsStorage {
fun saveHoldToSpeedEnabled(enabled: Boolean)
fun loadHoldToSpeedValue(): Float?
fun saveHoldToSpeedValue(speed: Float)
fun loadTouchGesturesEnabled(): Boolean?
fun saveTouchGesturesEnabled(enabled: Boolean)
fun loadExternalPlayerEnabled(): Boolean?
fun saveExternalPlayerEnabled(enabled: Boolean)
fun loadExternalPlayerForwardSubtitles(): Boolean?
@ -106,6 +108,8 @@ internal expect object PlayerSettingsStorage {
fun saveIosTargetTransfer(transfer: String)
fun loadIosHardwareDecoderMode(): String?
fun saveIosHardwareDecoderMode(mode: String)
fun loadIosAudioOutputMode(): String?
fun saveIosAudioOutputMode(mode: String)
fun loadIosExtendedDynamicRangeEnabled(): Boolean?
fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean)
fun loadIosTargetColorspaceHintEnabled(): Boolean?

View file

@ -15,20 +15,15 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator
@ -41,18 +36,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioTokens
import com.nuvio.app.core.ui.nuvio
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.streams.StreamBadgeImage
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamFileSizeBadge
import com.nuvio.app.features.streams.StreamCard
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.isSelectableForPlayback
@ -71,7 +61,7 @@ fun PlayerSourcesPanel(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
val debridSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
@ -83,8 +73,8 @@ fun PlayerSourcesPanel(
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)),
exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)),
) {
Box(
modifier = modifier
@ -94,22 +84,24 @@ fun PlayerSourcesPanel(
interactionSource = remember { MutableInteractionSource() },
onClick = onDismiss,
)
.background(colorScheme.scrim.copy(alpha = 0.52f)),
.background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)),
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)),
exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)),
enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } +
fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)),
exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } +
fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)),
) {
Box(
modifier = Modifier
.widthIn(max = 520.dp)
.widthIn(max = tokens.components.playerPanelMaxWidth)
.fillMaxWidth(0.92f)
.heightIn(max = 600.dp)
.clip(RoundedCornerShape(24.dp))
.background(colorScheme.surface)
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp))
.heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s40)
.clip(tokens.shapes.playerPanel)
.background(tokens.colors.surfaceSheet)
.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
@ -121,17 +113,17 @@ fun PlayerSourcesPanel(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.compose_player_panel_sources),
color = colorScheme.onSurface,
fontSize = 18.sp,
color = tokens.colors.textPrimary,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) {
PanelChipButton(
label = stringResource(Res.string.compose_action_reload),
icon = Icons.Rounded.Refresh,
@ -153,9 +145,9 @@ fun PlayerSourcesPanel(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = tokens.spacing.sheetPadding)
.padding(bottom = tokens.spacing.listGap),
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
) {
AddonFilterChip(
label = stringResource(Res.string.collections_tab_all),
@ -181,13 +173,13 @@ fun PlayerSourcesPanel(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(vertical = NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = colorScheme.primary,
strokeWidth = 2.dp,
modifier = Modifier.size(28.dp),
color = tokens.colors.accent,
strokeWidth = tokens.borders.medium,
modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4),
)
}
}
@ -196,13 +188,13 @@ fun PlayerSourcesPanel(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(vertical = NuvioTokens.Space.s40),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.compose_player_no_streams_found),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@ -210,9 +202,9 @@ fun PlayerSourcesPanel(
else -> {
val streams = streamsUiState.filteredGroups.flatMap { it.streams }
LazyColumn(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
) {
itemsIndexed(
items = streams,
@ -223,11 +215,16 @@ fun PlayerSourcesPanel(
currentUrl = currentStreamUrl,
currentName = currentStreamName,
)
SourceStreamRow(
StreamCard(
stream = stream,
isCurrent = isCurrent,
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
!debridSettings.hasCustomStreamFormatting,
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
showAddonLogo = streamBadgeSettings.showAddonLogo,
badgePlacement = streamBadgeSettings.badgePlacement,
isCurrent = isCurrent,
currentLabel = stringResource(Res.string.compose_player_playing),
onClick = { onStreamSelected(stream) },
)
}
@ -241,117 +238,6 @@ fun PlayerSourcesPanel(
}
}
@Composable
private fun SourceStreamRow(
stream: StreamItem,
isCurrent: Boolean,
enabled: Boolean,
showFileSizeBadges: Boolean,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 68.dp)
.shadow(
elevation = 2.dp,
shape = cardShape,
ambientColor = Color.Black.copy(alpha = 0.04f),
spotColor = Color.Black.copy(alpha = 0.04f),
)
.clip(cardShape)
.background(
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f),
)
.then(
if (isCurrent) {
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape)
} else {
Modifier
},
)
.clickable(enabled = enabled, onClick = onClick)
.padding(14.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = stream.streamLabel,
color = colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(colorScheme.primaryContainer)
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = stringResource(Res.string.compose_player_playing),
color = colorScheme.onPrimaryContainer,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
}
val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
lineHeight = 18.sp,
),
color = colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(6.dp))
val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
badgeImages.forEach { badge ->
StreamBadgeImage(badge = badge)
}
if (showFileSizeBadges) {
StreamFileSizeBadge(stream = stream)
}
Text(
text = stream.addonName,
modifier = if (hasBadgeMetadata) Modifier.padding(start = 4.dp) else Modifier,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
internal fun AddonFilterChip(
label: String,
@ -360,46 +246,46 @@ internal fun AddonFilterChip(
hasError: Boolean = false,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
Box(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.clip(tokens.shapes.chip)
.background(
when {
isSelected -> colorScheme.primaryContainer
else -> colorScheme.surfaceVariant.copy(alpha = 0.92f)
isSelected -> tokens.colors.overlaySelected
else -> tokens.colors.surfacePopover
},
)
.then(
if (isSelected) {
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip)
} else {
Modifier.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.7f), RoundedCornerShape(20.dp))
Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip)
},
)
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
) {
if (isLoading) {
CircularProgressIndicator(
color = colorScheme.primary,
strokeWidth = 1.5.dp,
modifier = Modifier.size(12.dp),
color = tokens.colors.accent,
strokeWidth = tokens.borders.thin + NuvioTokens.Space.hairline,
modifier = Modifier.size(NuvioTokens.Icon.xs),
)
}
Text(
text = label,
color = when {
hasError -> colorScheme.error
isSelected -> colorScheme.onPrimaryContainer
else -> colorScheme.onSurfaceVariant
hasError -> tokens.colors.danger
isSelected -> tokens.colors.textPrimary
else -> tokens.colors.textMuted
},
fontSize = 12.sp,
fontSize = NuvioTokens.Type.labelSm,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
)
}
@ -412,32 +298,32 @@ internal fun PanelChipButton(
onClick: () -> Unit,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
) {
val colorScheme = MaterialTheme.colorScheme
val tokens = MaterialTheme.nuvio
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(colorScheme.surfaceVariant.copy(alpha = 0.9f))
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.7f), RoundedCornerShape(16.dp))
.clip(tokens.shapes.compactCard)
.background(tokens.colors.surfacePopover)
.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
.padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
tint = tokens.colors.textMuted,
modifier = Modifier.size(NuvioTokens.Space.s14),
)
}
Text(
text = label,
color = colorScheme.onSurfaceVariant,
fontSize = 12.sp,
color = tokens.colors.textMuted,
fontSize = NuvioTokens.Type.labelSm,
)
}
}

View file

@ -12,28 +12,35 @@ import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.LocalDebridAvailabilityService
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamWarmupRepository
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.InstalledStreamAddonTarget
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamBadgePresentation
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLoadCompletion
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.streams.runCatchingUnlessCancelled
import com.nuvio.app.features.streams.sortedForGroupedDisplay
import com.nuvio.app.features.streams.streamAddonInstanceId
import com.nuvio.app.features.streams.toEmptyStateReason
import com.nuvio.app.features.streams.toPluginProviderGroups
import com.nuvio.app.features.streams.toStreamItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
/**
* Dedicated stream fetcher for use inside the player (sources & episodes panels).
@ -130,7 +137,13 @@ object PlayerStreamsRepository {
jobHolder: () -> Job?,
setJob: (Job) -> Unit,
) {
val requestKey = "$type::$videoId::$season::$episode"
val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.uiState.value
} else {
PluginsUiState(pluginsEnabled = false)
}
val requestKey = "$type::$videoId::$season::$episode::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val current = stateFlow.value
if (
!forceRefresh &&
@ -167,18 +180,20 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons.enabledAddons()
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
val debridSettings = DebridSettingsRepository.snapshot()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
} else {
emptyList()
}
val pluginProviderGroups = pluginScrapers.toPluginProviderGroups(
repositories = pluginUiState.repositories,
groupByRepository = pluginUiState.groupStreamsByRepository,
)
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@ -197,14 +212,14 @@ object PlayerStreamsRepository {
}
if (!supportsRequestedStream) return@mapNotNull null
PlayerInstalledStreamAddonTarget(
InstalledStreamAddonTarget(
addonName = addon.displayTitle.ifBlank { manifest.name },
addonId = addon.streamAddonInstanceId(manifest.id),
manifest = manifest,
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@ -213,22 +228,17 @@ object PlayerStreamsRepository {
}
val installedAddonOrder = streamAddons.map { it.addonName }
val warmedAddonGroups = AddonStreamWarmupRepository
.cachedGroups(type = type, videoId = videoId, season = season, episode = episode)
.orEmpty()
.associateBy { it.addonId }
val warmedAddonIds = warmedAddonGroups.keys
val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon ->
warmedAddonGroups[addon.addonId] ?: AddonStreamGroup(
AddonStreamGroup(
addonName = addon.addonName,
addonId = addon.addonId,
streams = emptyList(),
isLoading = true,
)
} + pluginScrapers.map { scraper ->
} + pluginProviderGroups.map { providerGroup ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
addonName = providerGroup.addonName,
addonId = providerGroup.addonId,
streams = emptyList(),
isLoading = true,
)
@ -241,19 +251,21 @@ object PlayerStreamsRepository {
)
val job = scope.launch {
val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds }
val installedAddonIds = streamAddons.map { it.addonId }.toSet()
val installedAddonNames = installedAddonOrder.toSet()
val pluginRemainingByAddonId = pluginProviderGroups
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size }
val completions = Channel<StreamLoadCompletion>(capacity = Channel.BUFFERED)
val debridAvailabilityJobs = mutableListOf<Job>()
fun emptyStateReason(groups: List<AddonStreamGroup>, anyLoading: Boolean) =
if (!anyLoading && groups.all { it.streams.isEmpty() }) {
if (groups.all { !it.error.isNullOrBlank() }) {
com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed
} else {
com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound
}
} else {
null
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late player stream load completion after channel close" }
}
}
fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup {
val badgeGroup = StreamBadgePresentation.apply(
@ -278,7 +290,7 @@ object PlayerStreamsRepository {
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = emptyStateReason(updated, anyLoading),
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
}
@ -314,8 +326,8 @@ object PlayerStreamsRepository {
debridAvailabilityJobs += availabilityJob
}
val addonJobs = pendingStreamAddons.map { addon ->
async {
streamAddons.forEach { addon ->
launch {
val url = buildAddonResourceUrl(
manifestUrl = addon.manifest.transportUrl,
resource = "stream",
@ -324,9 +336,14 @@ object PlayerStreamsRepository {
)
val displayName = addon.addonName
runCatching {
val group = runCatchingUnlessCancelled {
val payload = httpGetText(url)
StreamParser.parse(payload, displayName, addon.addonId)
StreamParser.parse(
payload = payload,
addonName = displayName,
addonId = addon.addonId,
addonLogo = addon.manifest.logoUrl,
)
}.fold(
onSuccess = { streams ->
AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false)
@ -336,55 +353,103 @@ object PlayerStreamsRepository {
AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message)
},
)
publishCompletion(StreamLoadCompletion.Addon(group))
}
}
val pluginJobs = pluginScrapers.map { scraper ->
async {
PluginRepository.executeScraper(
scraper = scraper,
tmdbId = pluginContentId(
videoId = videoId,
pluginProviderGroups.forEach { providerGroup ->
val includeScraperNameInSubtitle = false
providerGroup.scrapers.forEach { scraper ->
launch {
val completion = PluginRepository.executeScraper(
scraper = scraper,
tmdbId = pluginContentId(
videoId = videoId,
season = season,
episode = episode,
),
mediaType = type,
season = season,
episode = episode,
),
mediaType = type,
season = season,
episode = episode,
).fold(
onSuccess = { results ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = results.map { it.toStreamItem(scraper) },
isLoading = false,
)
},
onFailure = { err ->
log.w(err) { "Plugin scraper failed: ${scraper.name}" }
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = false,
error = err.message,
)
},
)
).fold(
onSuccess = { results ->
StreamLoadCompletion.PluginScraper(
addonId = providerGroup.addonId,
streams = results.map { result ->
result.toStreamItem(
scraper = scraper,
addonName = providerGroup.addonName,
addonId = providerGroup.addonId,
includeScraperNameInSubtitle = includeScraperNameInSubtitle,
)
},
error = null,
)
},
onFailure = { error ->
log.w(error) { "Plugin scraper failed: ${scraper.name}" }
StreamLoadCompletion.PluginScraper(
addonId = providerGroup.addonId,
streams = emptyList(),
error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name),
)
},
)
publishCompletion(completion)
}
}
}
val jobs = addonJobs + pluginJobs
val completions = Channel<AddonStreamGroup>(capacity = Channel.BUFFERED)
jobs.forEach { deferred ->
launch {
completions.send(deferred.await())
repeat(totalTasks) {
when (val completion = completions.receive()) {
is StreamLoadCompletion.Addon -> {
publishStreamGroupAfterCacheCheck(completion.group)
}
is StreamLoadCompletion.PluginScraper -> {
val remaining = (pluginRemainingByAddonId[completion.addonId] ?: 1) - 1
pluginRemainingByAddonId[completion.addonId] = remaining.coerceAtLeast(0)
if (!completion.error.isNullOrBlank() && pluginFirstErrorByAddonId[completion.addonId].isNullOrBlank()) {
pluginFirstErrorByAddonId[completion.addonId] = completion.error
}
stateFlow.update { current ->
val updated = StreamAutoPlaySelector.orderAddonStreams(
groups = current.groups.map { group ->
if (group.addonId != completion.addonId) {
group
} else {
val mergedStreams = if (completion.streams.isEmpty()) {
group.streams
} else {
(group.streams + completion.streams).sortedForGroupedDisplay()
}
val stillLoading = remaining > 0
val finalError = if (mergedStreams.isEmpty() && !stillLoading) {
pluginFirstErrorByAddonId[completion.addonId]
} else {
null
}
group.copy(
streams = mergedStreams,
isLoading = stillLoading,
error = finalError,
)
}
},
installedOrder = installedAddonOrder,
)
val anyLoading = updated.any { it.isLoading }
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
}
}
}
repeat(jobs.size) {
val result = completions.receive()
publishStreamGroupAfterCacheCheck(result)
}
for (availabilityJob in debridAvailabilityJobs) {
availabilityJob.join()
}
@ -415,49 +480,3 @@ object PlayerStreamsRepository {
setJob(job)
}
}
private data class PlayerInstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
val subtitleParts = listOfNotNull(
quality?.takeIf { it.isNotBlank() },
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
val requestHeaders = headers
.orEmpty()
.mapNotNull { (key, value) ->
val headerName = key.trim()
val headerValue = value.trim()
if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) {
null
} else {
headerName to headerValue
}
}
.toMap()
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
url = url,
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
behaviorHints = if (requestHeaders.isEmpty()) {
com.nuvio.app.features.streams.StreamBehaviorHints()
} else {
com.nuvio.app.features.streams.StreamBehaviorHints(
notWebReady = true,
proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders),
)
},
)
}

Some files were not shown because too many files have changed in this diff Show more