mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-30 08:09:21 +00:00
Merge branch 'cmp-rewrite' into pr/liquid-glass-profile-switch
This commit is contained in:
commit
53d600768f
168 changed files with 11351 additions and 6066 deletions
26
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
26
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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`
|
||||
|
|
|
|||
102
.github/workflows/triage-needs-info.yml
vendored
102
.github/workflows/triage-needs-info.yml
vendored
|
|
@ -32,8 +32,12 @@ jobs:
|
|||
return labels.includes(name.toLowerCase());
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractSection(title) {
|
||||
const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
|
||||
const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m");
|
||||
const match = body.match(re);
|
||||
if (!match) return "";
|
||||
const start = match.index + match[0].length;
|
||||
|
|
@ -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
3
.gitmodules
vendored
|
|
@ -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
2
MPVKit
|
|
@ -1 +1 @@
|
|||
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
|
||||
Subproject commit 20afe97c34e46fce08a3794e2afa64613ea62794
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)($|[=/_.?&%-])")
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage {
|
|||
?.putString(key, payload)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun removePayload(key: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(key)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/" />
|
||||
|
|
|
|||
|
|
@ -320,8 +320,11 @@
|
|||
<string name="compose_nav_search">Rechercher</string>
|
||||
<string name="compose_player_audio_tracks">Pistes audio</string>
|
||||
<string name="compose_player_audio">Audio</string>
|
||||
<string name="compose_player_auto_sync">Synchro auto</string>
|
||||
<string name="compose_player_bold">Gras</string>
|
||||
<string name="compose_player_built_in">Intégré</string>
|
||||
<string name="compose_player_bottom_offset">Décalage inférieur</string>
|
||||
<string name="compose_player_capture_line">Capturer</string>
|
||||
<string name="compose_player_close">Fermer le lecteur</string>
|
||||
<string name="compose_player_color">Couleur</string>
|
||||
<string name="compose_player_currently_playing">En cours de lecture</string>
|
||||
|
|
@ -332,11 +335,15 @@
|
|||
<string name="compose_player_font_size">Taille de police</string>
|
||||
<string name="compose_player_font_size_value">%1$dsp</string>
|
||||
<string name="compose_player_lock_controls">Verrouiller les contrôles du lecteur</string>
|
||||
<string name="compose_player_loading_lines">Chargement des lignes de sous-titres…</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">Aucune ligne de sous-titres trouvée</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Impossible de charger les lignes de sous-titres</string>
|
||||
<string name="compose_player_no_audio_tracks_available">Aucune piste audio disponible</string>
|
||||
<string name="compose_player_no_episodes_available">Aucun épisode disponible</string>
|
||||
<string name="compose_player_no_streams_found">Aucun stream trouvé</string>
|
||||
<string name="compose_player_none">Aucun</string>
|
||||
<string name="compose_player_outline">Contour</string>
|
||||
<string name="compose_player_outline_color">Couleur du contour</string>
|
||||
<string name="compose_player_panel_episodes">Épisodes</string>
|
||||
<string name="compose_player_panel_sources">Sources</string>
|
||||
<string name="compose_player_panel_streams">Streams</string>
|
||||
|
|
@ -344,6 +351,8 @@
|
|||
<string name="compose_player_playing">Lecture en cours</string>
|
||||
<string name="compose_player_fetch_subtitles">Appuie pour chercher des sous-titres</string>
|
||||
<string name="compose_player_go_back">Retour</string>
|
||||
<string name="compose_player_reload">Recharger</string>
|
||||
<string name="compose_player_reset">Réinitialiser</string>
|
||||
<string name="compose_player_reset_defaults">Rétablir les valeurs par défaut</string>
|
||||
<string name="compose_player_resize_fill">Remplir</string>
|
||||
<string name="compose_player_resize_fit">Ajuster</string>
|
||||
|
|
@ -356,8 +365,11 @@
|
|||
<string name="compose_player_seek_forward_10">Avancer de 10 secondes</string>
|
||||
<string name="compose_player_sources">Sources</string>
|
||||
<string name="compose_player_style">Style</string>
|
||||
<string name="compose_player_select_addon_subtitle_first">Sélectionne d’abord un sous-titre d’addon</string>
|
||||
<string name="compose_player_subs">Sous-titres</string>
|
||||
<string name="compose_player_subtitle_delay">Délai des sous-titres</string>
|
||||
<string name="compose_player_subtitles">Sous-titres</string>
|
||||
<string name="compose_player_text_opacity">Opacité du texte</string>
|
||||
<string name="compose_player_brightness_level">Luminosité %1$s</string>
|
||||
<string name="compose_player_volume_level">Volume %1$s</string>
|
||||
<string name="compose_player_muted">Muet</string>
|
||||
|
|
@ -386,6 +398,7 @@
|
|||
<string name="compose_settings_category_general">Général</string>
|
||||
<string name="compose_settings_page_account">Compte</string>
|
||||
<string name="compose_settings_page_addons">Addons</string>
|
||||
<string name="compose_settings_page_advanced">Avancé</string>
|
||||
<string name="compose_settings_page_appearance">Apparence</string>
|
||||
<string name="compose_settings_page_content_discovery">Contenu et découverte</string>
|
||||
<string name="compose_settings_page_continue_watching">Continuer à regarder</string>
|
||||
|
|
@ -400,12 +413,15 @@
|
|||
<string name="compose_settings_page_plugins">Plugins</string>
|
||||
<string name="compose_settings_page_poster_customization">Personnalisation des affiches</string>
|
||||
<string name="compose_settings_page_root">Paramètres</string>
|
||||
<string name="compose_settings_page_streams">Streams</string>
|
||||
<string name="compose_settings_page_supporters_contributors">Supporters et contributeurs</string>
|
||||
<string name="compose_settings_page_tmdb_enrichment">Enrichissement TMDB</string>
|
||||
<string name="compose_settings_page_trakt">Trakt</string>
|
||||
<string name="compose_settings_root_about_section">À PROPOS</string>
|
||||
<string name="compose_settings_root_account_description">Gère ton compte, déconnecte-toi ou supprime-le.</string>
|
||||
<string name="compose_settings_root_account_section">COMPTE</string>
|
||||
<string name="compose_settings_root_advanced_description">Comportement au démarrage et des profils.</string>
|
||||
<string name="compose_settings_root_advanced_section">AVANCÉ</string>
|
||||
<string name="compose_settings_root_appearance_description">Ajuste la présentation de l’accueil et les préférences visuelles.</string>
|
||||
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l’application.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
|
||||
|
|
@ -415,12 +431,20 @@
|
|||
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Connecte les services TMDB et MDBList.</string>
|
||||
<string name="compose_settings_root_notifications_description">Gère les alertes de sortie d’épisodes et envoie une notification de test.</string>
|
||||
<string name="compose_settings_root_streams_description">Affichage des résultats de streams et règles d’URL de badges.</string>
|
||||
<string name="compose_settings_root_switch_profile_description">Basculer vers un profil différent.</string>
|
||||
<string name="compose_settings_root_switch_profile_title">Changer de profil</string>
|
||||
<string name="compose_settings_root_trakt_description">Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt.</string>
|
||||
<string name="settings_search_empty">Aucun paramètre trouvé.</string>
|
||||
<string name="settings_search_placeholder">Rechercher dans les paramètres…</string>
|
||||
<string name="settings_search_results_section">RÉSULTATS</string>
|
||||
<string name="settings_advanced_section_startup">DÉMARRAGE</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_remember_last_profile">Mémoriser le dernier profil</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Mémorise le dernier profil sélectionné au démarrage</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Vider le cache de Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache vidé</string>
|
||||
<string name="settings_licenses_attributions_section_app">LICENCE DE L’APP</string>
|
||||
<string name="settings_licenses_attributions_section_data">DONNÉES ET SERVICES</string>
|
||||
<string name="settings_licenses_attributions_section_playback">LICENCE DE LECTURE</string>
|
||||
|
|
@ -584,6 +608,8 @@
|
|||
<string name="settings_continue_watching_section_visibility">VISIBILITÉ</string>
|
||||
<string name="settings_continue_watching_show_description">Afficher le bandeau Continuer à regarder sur l’écran d’accueil.</string>
|
||||
<string name="settings_continue_watching_show_title">Afficher Continuer à regarder</string>
|
||||
<string name="settings_continue_watching_style_card">Carte</string>
|
||||
<string name="settings_continue_watching_style_card_description">Carte paysage façon TV</string>
|
||||
<string name="settings_continue_watching_style_poster">Affiche</string>
|
||||
<string name="settings_continue_watching_style_poster_description">Carte d’affiche centrée sur la couverture</string>
|
||||
<string name="settings_continue_watching_style_wide">Large</string>
|
||||
|
|
@ -646,6 +672,38 @@
|
|||
<string name="settings_debrid_description_template_description">Contrôle les métadonnées affichées sous chaque résultat.</string>
|
||||
<string name="settings_debrid_formatter_reset_title">Réinitialiser la mise en forme</string>
|
||||
<string name="settings_debrid_formatter_reset_subtitle">Restaurer la mise en forme par défaut des résultats.</string>
|
||||
<string name="settings_stream_badges_section">Style Fusion</string>
|
||||
<string name="settings_stream_size_badges_title">Badges de taille</string>
|
||||
<string name="settings_stream_size_badges_description">Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur.</string>
|
||||
<string name="settings_stream_addon_logo_title">Logo de l’addon</string>
|
||||
<string name="settings_stream_addon_logo_description">Affiche le logo et le nom de l’addon à côté des sources de streams.</string>
|
||||
<string name="settings_stream_display_section">Affichage</string>
|
||||
<string name="settings_stream_badge_position_title">Position des badges</string>
|
||||
<string name="settings_stream_badge_position_description">Choisis si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">Position des badges</string>
|
||||
<string name="settings_stream_badge_position_dialog_description">Choisis où les badges de flux apparaissent sur les cartes de flux.</string>
|
||||
<string name="settings_stream_badge_position_top">En haut</string>
|
||||
<string name="settings_stream_badge_position_bottom">En bas</string>
|
||||
<string name="settings_stream_badge_urls_title">URL de badges Fusion</string>
|
||||
<string name="settings_stream_badge_urls_description">Importe jusqu’à %1$d URL JSON de badges de flux de style Fusion. Chaque URL peut être mise à jour ou supprimée séparément.</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Gère les URL JSON de badges de flux de style Fusion importées.</string>
|
||||
<string name="settings_stream_badge_import_failed">Échec de l’import du badge.</string>
|
||||
<string name="settings_stream_badge_enter_url">Saisis une URL JSON de badge.</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">L’URL du badge doit commencer par http:// ou https://.</string>
|
||||
<string name="settings_stream_badge_import_limit">Tu peux importer jusqu’à %1$d URL de badges.</string>
|
||||
<string name="settings_fusion_badges_summary">%1$d/%2$d URL, %3$d badges Fusion actifs</string>
|
||||
<string name="settings_fusion_badges_empty">Aucune URL de badge Fusion importée.</string>
|
||||
<string name="settings_fusion_badge_url_label">URL JSON du badge Fusion</string>
|
||||
<string name="settings_fusion_badge_urls_imported">%1$d/%2$d URL Fusion importées</string>
|
||||
<string name="settings_fusion_badge_url_active">Active</string>
|
||||
<string name="settings_fusion_badge_url_inactive">Inactive</string>
|
||||
<string name="settings_fusion_badge_url_status_summary">%1$s · %2$d badges activés · %3$d groupes</string>
|
||||
<string name="settings_fusion_badge_preview_action">Aperçu</string>
|
||||
<string name="settings_fusion_badge_preview_title">Aperçu du badge Fusion</string>
|
||||
<string name="settings_fusion_badge_preview_count">%1$d badges de style Fusion depuis cette URL</string>
|
||||
<string name="settings_fusion_badge_preview_empty">Aucune image de badge de style Fusion dans cette URL.</string>
|
||||
<string name="settings_fusion_badge_group_title">Groupe %1$d</string>
|
||||
<string name="settings_fusion_badge_other_group_title">Autres badges Fusion</string>
|
||||
<string name="settings_debrid_key_valid">Clé API validée.</string>
|
||||
<string name="settings_debrid_key_invalid">Impossible de valider cette clé API.</string>
|
||||
<string name="settings_mdb_add_api_key_first">Ajoute ta clé API MDBList ci-dessous avant d’activer les notes.</string>
|
||||
|
|
@ -669,6 +727,8 @@
|
|||
<string name="settings_meta_comments_description">Section de commentaires Trakt.</string>
|
||||
<string name="settings_meta_details">Détails</string>
|
||||
<string name="settings_meta_details_description">Durée, statut, date de sortie, langue et informations associées.</string>
|
||||
<string name="settings_meta_hero_trailer_playback">Lecture des bandes-annonces dans le Hero</string>
|
||||
<string name="settings_meta_hero_trailer_playback_description">Lit un aperçu de la bande-annonce dans le Hero des métadonnées quand une bande-annonce est disponible.</string>
|
||||
<string name="settings_meta_episode_cards">Cartes d’épisodes</string>
|
||||
<string name="settings_meta_episode_cards_description">Choisis comment les épisodes sont affichés sur l’écran de métadonnées.</string>
|
||||
<string name="settings_meta_episode_style_horizontal">Horizontal</string>
|
||||
|
|
@ -768,10 +828,18 @@
|
|||
<string name="settings_playback_external_player_app">App de lecteur externe</string>
|
||||
<string name="settings_playback_external_player_description_android">Ouvre les nouvelles lectures avec l’app vidéo par défaut d’Android ou le sélecteur système.</string>
|
||||
<string name="settings_playback_external_player_description_ios">Ouvre les nouvelles lectures avec le lecteur installé sélectionné.</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Transmettre les sous-titres au lecteur externe</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Récupère les sous-titres d’addons dans ta langue préférée et les transmet au lecteur externe.</string>
|
||||
<string name="settings_playback_external_player_none_available">Aucun lecteur externe compatible n’est installé</string>
|
||||
<string name="settings_playback_player_preference">Lecteur</string>
|
||||
<string name="settings_playback_player_preference_internal">Interne</string>
|
||||
<string name="settings_playback_player_preference_external">Externe</string>
|
||||
<string name="settings_playback_player_preference_description">Choisis quel lecteur gère les nouvelles lectures.</string>
|
||||
<string name="settings_playback_hold_speed">Vitesse au maintien</string>
|
||||
<string name="settings_playback_hold_to_speed">Maintenir pour accélérer</string>
|
||||
<string name="settings_playback_hold_to_speed_description">Garde le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement.</string>
|
||||
<string name="settings_playback_touch_gestures">Gestes tactiles</string>
|
||||
<string name="settings_playback_touch_gestures_description">Autorise les balayages et les doubles appuis sur le lecteur pour avancer ou reculer, ajuster la luminosité ou le volume.</string>
|
||||
<string name="settings_playback_invalid_regex_pattern">Modèle regex invalide</string>
|
||||
<string name="settings_playback_last_link_cache_duration">Durée du cache du dernier lien</string>
|
||||
<string name="settings_playback_map_dv7_to_hevc">Mapper DV7 vers HEVC</string>
|
||||
|
|
@ -785,6 +853,13 @@
|
|||
<string name="settings_playback_option_device_language">Langue de l’appareil</string>
|
||||
<string name="settings_playback_option_forced">Forcé</string>
|
||||
<string name="settings_playback_option_none">Aucun</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all">Tous les sous-titres</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all_description">Récupère et affiche tous les sous-titres d’addons pour la vidéo.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast">Démarrage rapide</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast_description">Ignore la récupération automatique des sous-titres d’addons jusqu’à ce que tu la demandes dans le lecteur.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_mode">Sous-titres d’addons au démarrage</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred">Préférés uniquement</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred_description">Récupère les sous-titres d’addons, mais n’affiche que ceux dans tes langues préférées.</string>
|
||||
<string name="settings_playback_prefer_binge_group">Préférer le groupe binge</string>
|
||||
<string name="settings_playback_prefer_binge_group_description">Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel.</string>
|
||||
<string name="settings_playback_reuse_binge_group">Réutiliser le groupe de binge</string>
|
||||
|
|
@ -829,6 +904,20 @@
|
|||
<string name="settings_playback_selected_count">%1$d sélectionné(s)</string>
|
||||
<string name="settings_playback_show_loading_overlay">Afficher la superposition de chargement</string>
|
||||
<string name="settings_playback_show_loading_overlay_description">Afficher la superposition de chargement initiale pendant le démarrage d’un stream.</string>
|
||||
<string name="settings_playback_subtitle_background_color">Couleur d’arrière-plan</string>
|
||||
<string name="settings_playback_subtitle_bold">Gras</string>
|
||||
<string name="settings_playback_subtitle_bold_description">Utilise une graisse de police plus épaisse pour les sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_color_transparent">Transparent</string>
|
||||
<string name="settings_playback_subtitle_outline">Contour</string>
|
||||
<string name="settings_playback_subtitle_outline_color">Couleur du contour</string>
|
||||
<string name="settings_playback_subtitle_outline_description">Dessine une bordure autour du texte des sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only">Afficher uniquement les langues préférées</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only_description">N’affiche que les sous-titres correspondant à tes langues de sous-titres préférées.</string>
|
||||
<string name="settings_playback_subtitle_size">Taille des sous-titres</string>
|
||||
<string name="settings_playback_subtitle_text_color">Couleur du texte</string>
|
||||
<string name="settings_playback_subtitle_use_forced">Utiliser les sous-titres forcés</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Privilégie les sous-titres forcés selon tes préférences de langue de sous-titres.</string>
|
||||
<string name="settings_playback_subtitle_vertical_offset">Décalage vertical</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap">Passer l’intro/outro/récap</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap_description">Afficher un bouton de saut lors des segments d’intro, d’outro et de récapitulatif détectés.</string>
|
||||
<string name="settings_playback_source_scope">Périmètre des sources</string>
|
||||
|
|
@ -928,6 +1017,12 @@
|
|||
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">Source de progression réglée sur Trakt</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">Source de progression réglée sur Nuvio Sync</string>
|
||||
<string name="trakt_more_like_this_source_title">Source de la section Similaires</string>
|
||||
<string name="trakt_more_like_this_source_subtitle">Choisis la provenance des recommandations sur les pages de détails</string>
|
||||
<string name="trakt_more_like_this_source_dialog_title">Source de la section Similaires</string>
|
||||
<string name="trakt_more_like_this_source_dialog_subtitle">Sélectionne la source des recommandations affichées sur les pages de détails.</string>
|
||||
<string name="trakt_more_like_this_source_trakt">Trakt</string>
|
||||
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
|
||||
<string name="trakt_continue_watching_window">Fenêtre de Continuer à regarder</string>
|
||||
<string name="trakt_continue_watching_subtitle">Historique Trakt pris en compte pour Continuer à regarder</string>
|
||||
<string name="trakt_cw_window_title">Fenêtre de Continuer à regarder</string>
|
||||
|
|
@ -1049,10 +1144,13 @@
|
|||
<string name="app_exit_title">Quitter l’application</string>
|
||||
<string name="catalog_empty_message">Ce catalogue n’a renvoyé aucun élément.</string>
|
||||
<string name="catalog_empty_title">Aucun titre trouvé</string>
|
||||
<string name="details_actions_menu_label">Plus d’actions</string>
|
||||
<string name="details_check_connection">Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie.</string>
|
||||
<string name="details_director">Réalisateur</string>
|
||||
<string name="details_failed_to_load">Échec du chargement</string>
|
||||
<string name="details_more_like_this">À voir aussi</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Données fournies par TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Données fournies par Trakt</string>
|
||||
<string name="details_seasons">Saisons</string>
|
||||
<string name="details_series_missing_numbers">Cet addon a renvoyé des vidéos pour la série, mais aucune n’incluait de numéros de saison ou d’épisode.</string>
|
||||
<string name="details_series_no_metadata">Cet addon n’a fourni aucune métadonnée d’épisode pour cette série.</string>
|
||||
|
|
@ -1084,6 +1182,8 @@
|
|||
<string name="episode_mark_watched">Marquer comme vu</string>
|
||||
<string name="home_continue_watching_up_next">À suivre</string>
|
||||
<string name="home_continue_watching_watched">%1$s vu</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$d h %2$d min restantes</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$d min restantes</string>
|
||||
<string name="home_empty_no_active_addons_message">Installe et valide au moins un addon avant de charger des lignes de catalogue à l’accueil.</string>
|
||||
<string name="home_empty_no_rows_message">Les addons installés n’exposent actuellement aucun catalogue compatible avec le tableau sans extras requis.</string>
|
||||
<string name="home_empty_no_rows_title">Aucune ligne d’accueil disponible</string>
|
||||
|
|
@ -1179,6 +1279,7 @@
|
|||
<string name="streams_episode_title_with_name">S%1$dE%2$d - %3$s</string>
|
||||
<string name="streams_fetching">Récupération…</string>
|
||||
<string name="streams_finding_source">Recherche de la source…</string>
|
||||
<string name="streams_loading_subtitles">Chargement des sous-titres depuis les addons…</string>
|
||||
<string name="streams_finding_streams">Recherche des streams…</string>
|
||||
<string name="streams_link_copied">Lien du stream copié</string>
|
||||
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
|
||||
|
|
@ -1283,6 +1384,16 @@
|
|||
<string name="trakt_sign_in_complete_failed">Impossible de terminer la connexion Trakt</string>
|
||||
<string name="trakt_user_fallback">Utilisateur Trakt</string>
|
||||
<string name="trakt_watchlist">Liste de suivi</string>
|
||||
<!-- Trakt library sync errors -->
|
||||
<string name="trakt_error_authorization_expired">Autorisation Trakt expirée</string>
|
||||
<string name="trakt_error_list_not_found">Liste Trakt introuvable</string>
|
||||
<string name="trakt_error_list_limit_reached">Limite de listes Trakt atteinte</string>
|
||||
<string name="trakt_error_rate_limit_reached">Limite de requêtes Trakt atteinte</string>
|
||||
<string name="trakt_error_request_failed">Échec de la requête Trakt</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Échec de l’ajout à ta liste de suivi Trakt</string>
|
||||
<string name="trakt_error_add_list_failed">Échec de l’ajout à la liste Trakt</string>
|
||||
<string name="trakt_error_missing_ids">Identifiants Trakt compatibles manquants</string>
|
||||
<string name="trakt_error_empty_response">Réponse vide du serveur</string>
|
||||
<string name="tmdb_sources_api_key_required">Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">Collection TMDB %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">Collection TMDB introuvable</string>
|
||||
|
|
@ -1483,6 +1594,11 @@
|
|||
<string name="settings_playback_ios_display_color_hint_desc">Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif.</string>
|
||||
<string name="settings_playback_ios_target_primaries">Primaires cibles</string>
|
||||
<string name="settings_playback_ios_target_transfer">Transfert cible</string>
|
||||
<string name="settings_playback_ios_audio_output_section">Sortie audio iOS</string>
|
||||
<string name="settings_playback_ios_audio_output">Sortie audio</string>
|
||||
<string name="settings_playback_ios_audio_output_auto_desc">Essaie d’abord AVFoundation, puis bascule sur AudioUnit.</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">Prise en charge expérimentale de l’audio spatial et de la sortie multicanal.</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">Utilise l’ancienne sortie AudioUnit.</string>
|
||||
<!-- Debrid Result Management section -->
|
||||
<string name="settings_debrid_section_result_management">Gestion des résultats</string>
|
||||
<string name="settings_debrid_max_results">Nombre max de résultats</string>
|
||||
|
|
@ -1498,6 +1614,7 @@
|
|||
<!-- Debrid misc -->
|
||||
<string name="settings_debrid_learn_more">En savoir plus</string>
|
||||
<string name="settings_debrid_template_default_format">Format par défaut</string>
|
||||
<string name="settings_debrid_template_original_format">Format d’origine</string>
|
||||
<string name="settings_debrid_release_groups_hint">Saisis un groupe par ligne.</string>
|
||||
<!-- Debrid sort profiles -->
|
||||
<string name="settings_debrid_sort_original">Ordre original</string>
|
||||
|
|
@ -1582,6 +1699,7 @@
|
|||
<string name="submit_intro_capture">Capturer</string>
|
||||
<!-- iOS advanced playback -->
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Décodeur matériel</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">Sortie audio</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Primaires cibles</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Transfert cible</string>
|
||||
<!-- Collection editor -->
|
||||
|
|
@ -1760,4 +1878,21 @@
|
|||
<!-- Pass 4 — platform stubs and dynamic title fallbacks -->
|
||||
<string name="collections_editor_trakt_list_title_format">Liste Trakt %1$s</string>
|
||||
<string name="external_player_android_system">Lecteur système Android</string>
|
||||
<string name="p2p_consent_title">Streaming P2P</string>
|
||||
<string name="p2p_consent_body">Ce flux utilise la technologie pair-à-pair (P2P). En activant le P2P, tu reconnais et acceptes que :\n\n• Ton adresse IP sera visible par les autres pairs du réseau\n• Tu es seul responsable du contenu auquel tu accèdes\n• Tu confirmes avoir le droit légal de visionner ce contenu dans ta juridiction\n• Nuvio n’héberge, ne distribue ni ne contrôle aucun contenu P2P\n• Nuvio ne peut être tenu responsable des conséquences légales liées à ton utilisation du streaming P2P\n\nTu utilises cette fonctionnalité entièrement à tes propres risques. Le P2P peut être désactivé à tout moment dans les paramètres.</string>
|
||||
<string name="p2p_consent_enable">Activer le P2P</string>
|
||||
<string name="p2p_consent_cancel">Annuler</string>
|
||||
<string name="p2p_error_unknown">Erreur torrent inconnue</string>
|
||||
<string name="settings_p2p_title">Streaming P2P</string>
|
||||
<string name="settings_p2p_subtitle">Autoriser les flux pair-à-pair (torrent)</string>
|
||||
<string name="settings_p2p_hide_stats_title">Masquer les statistiques torrent</string>
|
||||
<string name="settings_p2p_hide_stats_subtitle">Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture</string>
|
||||
<string name="player_torrent_peer_info">%1$d sources · %2$d pairs</string>
|
||||
<string name="player_torrent_buffered_status">%1$s en mémoire tampon · %2$s · %3$s</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_torrent_stats">%1$d pairs · %2$d sources · %3$d %%</string>
|
||||
<string name="player_torrent_connecting_peers">Connexion aux pairs…</string>
|
||||
<string name="player_torrent_starting_engine">Démarrage du moteur P2P…</string>
|
||||
<string name="player_error_failed_start_torrent">Impossible de démarrer le torrent : %1$s</string>
|
||||
<string name="player_error_torrent">Erreur de torrent : %1$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import nuvio.composeapp.generated.resources.action_resume
|
|||
import nuvio.composeapp.generated.resources.action_resume_episode
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found
|
||||
import nuvio.composeapp.generated.resources.compose_player_subtitle_lines_load_error
|
||||
import nuvio.composeapp.generated.resources.continue_watching_up_next
|
||||
import nuvio.composeapp.generated.resources.continue_watching_up_next_episode
|
||||
import nuvio.composeapp.generated.resources.date_month_april
|
||||
|
|
@ -40,6 +42,11 @@ import nuvio.composeapp.generated.resources.media_movie
|
|||
import nuvio.composeapp.generated.resources.media_movies
|
||||
import nuvio.composeapp.generated.resources.media_series
|
||||
import nuvio.composeapp.generated.resources.media_tv
|
||||
import nuvio.composeapp.generated.resources.p2p_error_unknown
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_enter_url
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_import_failed
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_import_limit
|
||||
import nuvio.composeapp.generated.resources.settings_stream_badge_url_scheme_invalid
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_b
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_gb
|
||||
import nuvio.composeapp.generated.resources.unit_bytes_kb
|
||||
|
|
@ -134,6 +141,27 @@ fun localizedShortMonthName(month: Int): String =
|
|||
else -> month.toString()
|
||||
}
|
||||
|
||||
fun localizedNoSubtitleLinesFound(): String =
|
||||
resourceString("No subtitle lines found") { getString(Res.string.compose_player_no_subtitle_lines_found) }
|
||||
|
||||
fun localizedSubtitleLinesLoadError(): String =
|
||||
resourceString("Unable to load subtitle lines") { getString(Res.string.compose_player_subtitle_lines_load_error) }
|
||||
|
||||
fun localizedBadgeImportFailed(): String =
|
||||
resourceString("Badge import failed.") { getString(Res.string.settings_stream_badge_import_failed) }
|
||||
|
||||
fun localizedBadgeEnterUrl(): String =
|
||||
resourceString("Enter a badge JSON URL.") { getString(Res.string.settings_stream_badge_enter_url) }
|
||||
|
||||
fun localizedBadgeUrlSchemeInvalid(): String =
|
||||
resourceString("Badge URL must start with http:// or https://.") { getString(Res.string.settings_stream_badge_url_scheme_invalid) }
|
||||
|
||||
fun localizedBadgeImportLimit(limit: Int): String =
|
||||
resourceString("You can import up to $limit badge URLs.") { getString(Res.string.settings_stream_badge_import_limit, limit) }
|
||||
|
||||
fun localizedP2pUnknownTorrentError(): String =
|
||||
resourceString("Unknown torrent error") { getString(Res.string.p2p_error_unknown) }
|
||||
|
||||
fun localizedByteUnit(unit: String): String =
|
||||
when (unit) {
|
||||
"GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) }
|
||||
|
|
|
|||
|
|
@ -1,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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
const val CATALOG_PAGE_SIZE = 100
|
||||
private const val DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT = 3
|
||||
|
||||
data class CatalogPage(
|
||||
val items: List<MetaPreview>,
|
||||
|
|
@ -22,6 +23,11 @@ data class CatalogPage(
|
|||
val nextSkip: Int?,
|
||||
)
|
||||
|
||||
data class CatalogPaginationState(
|
||||
val nextSkip: Int?,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
)
|
||||
|
||||
private val inflightMutex = Mutex()
|
||||
private val inflightRequestScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val inflightRequests = mutableMapOf<String, CompletableDeferred<String>>()
|
||||
|
|
@ -84,7 +90,40 @@ suspend fun fetchCatalogPage(
|
|||
}
|
||||
|
||||
fun AddonCatalog.supportsPagination(): Boolean =
|
||||
extra.any { property -> property.name == "skip" }
|
||||
extra.any { property -> property.name.equals("skip", ignoreCase = true) }
|
||||
|
||||
fun nextCatalogPaginationState(
|
||||
supportsPagination: Boolean,
|
||||
requestedSkip: Int,
|
||||
page: CatalogPage,
|
||||
loadedNewItems: Boolean,
|
||||
consecutiveDuplicatePages: Int,
|
||||
): CatalogPaginationState {
|
||||
if (!supportsPagination || page.rawItemCount <= 0 || page.nextSkip == null) {
|
||||
return CatalogPaginationState(nextSkip = null)
|
||||
}
|
||||
if (loadedNewItems) {
|
||||
return CatalogPaginationState(nextSkip = page.nextSkip)
|
||||
}
|
||||
|
||||
val duplicatePages = consecutiveDuplicatePages + 1
|
||||
val advancedSkip = if (page.nextSkip > requestedSkip) {
|
||||
page.nextSkip
|
||||
} else {
|
||||
requestedSkip + page.rawItemCount.coerceAtLeast(1)
|
||||
}
|
||||
return if (duplicatePages < DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT && advancedSkip > requestedSkip) {
|
||||
CatalogPaginationState(
|
||||
nextSkip = advancedSkip,
|
||||
consecutiveDuplicatePages = duplicatePages,
|
||||
)
|
||||
} else {
|
||||
CatalogPaginationState(
|
||||
nextSkip = null,
|
||||
consecutiveDuplicatePages = duplicatePages,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeCatalogItems(
|
||||
existing: List<MetaPreview>,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ data class CatalogUiState(
|
|||
val items: List<MetaPreview> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val canLoadMore: Boolean
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import com.nuvio.app.features.collection.CollectionRepository
|
||||
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
|
||||
import com.nuvio.app.features.collection.catalogRouteKey
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toMetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
import com.nuvio.app.features.trakt.TraktPublicListSourceResolver
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -16,8 +20,6 @@ import kotlinx.coroutines.launch
|
|||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library"
|
||||
|
||||
object CatalogRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _uiState = MutableStateFlow(CatalogUiState())
|
||||
|
|
@ -28,25 +30,15 @@ object CatalogRepository {
|
|||
private val scrollPositions = linkedMapOf<CatalogRequest, CatalogScrollPosition>()
|
||||
|
||||
fun load(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
force: Boolean = false,
|
||||
) {
|
||||
val request = catalogRequest(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
)
|
||||
val request = catalogRequest(target)
|
||||
if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) {
|
||||
return
|
||||
}
|
||||
activeRequest = request
|
||||
if (manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) {
|
||||
if (target is CatalogTarget.Library) {
|
||||
fetchInternalLibrary(request)
|
||||
return
|
||||
}
|
||||
|
|
@ -68,25 +60,17 @@ object CatalogRepository {
|
|||
}
|
||||
|
||||
fun scrollPosition(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
): CatalogScrollPosition =
|
||||
scrollPositions[catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)]
|
||||
scrollPositions[catalogRequest(target)]
|
||||
?: CatalogScrollPosition()
|
||||
|
||||
fun saveScrollPosition(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
target: CatalogTarget,
|
||||
firstVisibleItemIndex: Int,
|
||||
firstVisibleItemScrollOffset: Int,
|
||||
) {
|
||||
val request = catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)
|
||||
val request = catalogRequest(target)
|
||||
scrollPositions[request] = CatalogScrollPosition(
|
||||
firstVisibleItemIndex = firstVisibleItemIndex,
|
||||
firstVisibleItemScrollOffset = firstVisibleItemScrollOffset,
|
||||
|
|
@ -102,9 +86,10 @@ object CatalogRepository {
|
|||
|
||||
activeJob = scope.launch {
|
||||
runCatching {
|
||||
val target = request.target as CatalogTarget.Library
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState.value.sections
|
||||
.firstOrNull { it.type == request.catalogId }
|
||||
.firstOrNull { it.type == target.sectionType }
|
||||
?.items
|
||||
.orEmpty()
|
||||
.map { it.toMetaPreview() }
|
||||
|
|
@ -149,13 +134,22 @@ object CatalogRepository {
|
|||
|
||||
activeJob = scope.launch {
|
||||
runCatching {
|
||||
fetchCatalogPage(
|
||||
manifestUrl = request.manifestUrl,
|
||||
type = request.type,
|
||||
catalogId = request.catalogId,
|
||||
genre = request.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
).withUnreleasedFilter(request.hideUnreleasedContent)
|
||||
when (val target = request.target) {
|
||||
is CatalogTarget.Addon -> fetchCatalogPage(
|
||||
manifestUrl = target.manifestUrl,
|
||||
type = target.contentType,
|
||||
catalogId = target.catalogId,
|
||||
genre = target.genre,
|
||||
skip = requestedSkip.takeIf { it > 0 },
|
||||
)
|
||||
|
||||
is CatalogTarget.CollectionSource -> fetchCollectionSourcePage(
|
||||
target = target,
|
||||
page = requestedSkip.takeIf { it > 0 } ?: 1,
|
||||
)
|
||||
|
||||
is CatalogTarget.Library -> error(getString(Res.string.catalog_load_failed))
|
||||
}.withUnreleasedFilter(request.hideUnreleasedContent)
|
||||
}.fold(
|
||||
onSuccess = { page ->
|
||||
if (activeRequest != request) return@fold
|
||||
|
|
@ -165,12 +159,20 @@ object CatalogRepository {
|
|||
} else {
|
||||
mergeCatalogItems(_uiState.value.items, page.items)
|
||||
}
|
||||
val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val supportsPagination = request.target.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val loadedNewItems = reset || mergedItems.size > current.items.size
|
||||
val paginationState = nextCatalogPaginationState(
|
||||
supportsPagination = supportsPagination,
|
||||
requestedSkip = requestedSkip,
|
||||
page = page,
|
||||
loadedNewItems = loadedNewItems,
|
||||
consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages,
|
||||
)
|
||||
_uiState.value = CatalogUiState(
|
||||
items = mergedItems,
|
||||
isLoading = false,
|
||||
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
|
||||
nextSkip = paginationState.nextSkip,
|
||||
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
|
||||
errorMessage = null,
|
||||
)
|
||||
},
|
||||
|
|
@ -188,19 +190,9 @@ object CatalogRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun catalogRequest(
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
genre: String? = null,
|
||||
supportsPagination: Boolean = false,
|
||||
): CatalogRequest =
|
||||
private fun catalogRequest(target: CatalogTarget): CatalogRequest =
|
||||
CatalogRequest(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent,
|
||||
)
|
||||
}
|
||||
|
|
@ -211,11 +203,26 @@ private fun CatalogPage.withUnreleasedFilter(hideUnreleasedContent: Boolean): Ca
|
|||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
private suspend fun fetchCollectionSourcePage(
|
||||
target: CatalogTarget.CollectionSource,
|
||||
page: Int,
|
||||
): CatalogPage {
|
||||
CollectionRepository.initialize()
|
||||
val source = CollectionRepository.getCollection(target.collectionId)
|
||||
?.folders
|
||||
?.firstOrNull { it.id == target.folderId }
|
||||
?.resolvedSources
|
||||
?.firstOrNull { it.catalogRouteKey() == target.sourceKey }
|
||||
?: error(getString(Res.string.catalog_load_failed))
|
||||
|
||||
return when {
|
||||
source.isTmdb -> TmdbCollectionSourceResolver.resolve(source = source, page = page)
|
||||
source.isTrakt -> TraktPublicListSourceResolver.resolve(source = source, page = page)
|
||||
else -> error(getString(Res.string.catalog_load_failed))
|
||||
}
|
||||
}
|
||||
|
||||
private data class CatalogRequest(
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
val catalogId: String,
|
||||
val genre: String?,
|
||||
val supportsPagination: Boolean,
|
||||
val target: CatalogTarget,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -68,11 +68,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun CatalogScreen(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
manifestUrl: String,
|
||||
type: String,
|
||||
catalogId: String,
|
||||
supportsPagination: Boolean,
|
||||
genre: String? = null,
|
||||
target: CatalogTarget,
|
||||
onBack: () -> Unit,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
|
|
@ -87,19 +83,11 @@ fun CatalogScreen(
|
|||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val initialScrollPosition = remember(
|
||||
manifestUrl,
|
||||
type,
|
||||
catalogId,
|
||||
genre,
|
||||
supportsPagination,
|
||||
target,
|
||||
homeCatalogSettingsUiState.hideUnreleasedContent,
|
||||
) {
|
||||
CatalogRepository.scrollPosition(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
val gridState = rememberLazyGridState(
|
||||
|
|
@ -109,26 +97,18 @@ fun CatalogScreen(
|
|||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||
var observedOfflineState by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
LaunchedEffect(target, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(gridState, manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
LaunchedEffect(gridState, target, homeCatalogSettingsUiState.hideUnreleasedContent) {
|
||||
snapshotFlow { gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
CatalogRepository.saveScrollPosition(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
firstVisibleItemIndex = index,
|
||||
firstVisibleItemScrollOffset = offset,
|
||||
)
|
||||
|
|
@ -148,7 +128,7 @@ fun CatalogScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||
LaunchedEffect(networkStatusUiState.condition, target) {
|
||||
when (networkStatusUiState.condition) {
|
||||
NetworkCondition.NoInternet,
|
||||
NetworkCondition.ServersUnreachable,
|
||||
|
|
@ -160,11 +140,7 @@ fun CatalogScreen(
|
|||
if (!observedOfflineState) return@LaunchedEffect
|
||||
observedOfflineState = false
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
|
|
@ -208,11 +184,7 @@ fun CatalogScreen(
|
|||
onRetry = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
CatalogRepository.load(
|
||||
manifestUrl = manifestUrl,
|
||||
type = type,
|
||||
catalogId = catalogId,
|
||||
genre = genre,
|
||||
supportsPagination = supportsPagination,
|
||||
target = target,
|
||||
force = true,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface CatalogTarget {
|
||||
val contentType: String
|
||||
val supportsPagination: Boolean
|
||||
|
||||
data class Addon(
|
||||
val manifestUrl: String,
|
||||
override val contentType: String,
|
||||
val catalogId: String,
|
||||
val genre: String? = null,
|
||||
override val supportsPagination: Boolean = false,
|
||||
) : CatalogTarget
|
||||
|
||||
data class Library(
|
||||
override val contentType: String,
|
||||
val sectionType: String,
|
||||
) : CatalogTarget {
|
||||
override val supportsPagination: Boolean = false
|
||||
}
|
||||
|
||||
data class CollectionSource(
|
||||
val collectionId: String,
|
||||
val folderId: String,
|
||||
val sourceKey: String,
|
||||
override val contentType: String,
|
||||
override val supportsPagination: Boolean = false,
|
||||
) : CatalogTarget
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CatalogTargetKind {
|
||||
ADDON,
|
||||
LIBRARY,
|
||||
COLLECTION_SOURCE,
|
||||
}
|
||||
|
|
@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List<CollectionSource>): C
|
|||
)
|
||||
|
||||
private fun collectionSourceKey(source: CollectionSource): String =
|
||||
when {
|
||||
source.isTmdb -> {
|
||||
"tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}"
|
||||
}
|
||||
|
||||
source.isTrakt -> {
|
||||
"trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}"
|
||||
}
|
||||
}
|
||||
source.catalogRouteKey()
|
||||
|
||||
private fun selectedMediaTypes(
|
||||
state: CollectionEditorUiState,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,21 @@ data class CollectionSource(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun CollectionSource.catalogRouteKey(): String =
|
||||
when {
|
||||
isTmdb -> {
|
||||
"tmdb_${tmdbSourceType}_${tmdbId}_${mediaType}_${sortBy}_${filters.hashCode()}"
|
||||
}
|
||||
|
||||
isTrakt -> {
|
||||
"trakt_${traktListId}_${mediaType}_${TraktListSort.normalize(sortBy)}_${TraktSortHow.normalize(sortHow)}"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"addon_${addonId}_${type}_${catalogId}_${genre.orEmpty()}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun CollectionSource.hasInvalidTraktListId(): Boolean =
|
||||
isTrakt && (traktListId == null || traktListId <= 0L)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import co.touchlab.kermit.Logger
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE
|
||||
import com.nuvio.app.features.catalog.CatalogPage
|
||||
import com.nuvio.app.features.catalog.CatalogTarget
|
||||
import com.nuvio.app.features.catalog.fetchCatalogPage
|
||||
import com.nuvio.app.features.catalog.mergeCatalogItems
|
||||
import com.nuvio.app.features.catalog.nextCatalogPaginationState
|
||||
import com.nuvio.app.features.catalog.supportsPagination
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -35,6 +37,7 @@ data class FolderTab(
|
|||
val label: String,
|
||||
val typeLabel: String = "",
|
||||
val source: CollectionSource? = null,
|
||||
val sourceKey: String? = null,
|
||||
val manifestUrl: String? = null,
|
||||
val type: String = "",
|
||||
val catalogId: String = "",
|
||||
|
|
@ -44,6 +47,7 @@ data class FolderTab(
|
|||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val nextSkip: Int? = null,
|
||||
val consecutiveDuplicatePages: Int = 0,
|
||||
val error: String? = null,
|
||||
val isAllTab: Boolean = false,
|
||||
) {
|
||||
|
|
@ -136,7 +140,7 @@ object FolderDetailRepository {
|
|||
),
|
||||
)
|
||||
}
|
||||
sources.forEach { source ->
|
||||
sources.forEachIndexed { sourceIndex, source ->
|
||||
if (source.isTmdb) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
|
|
@ -145,6 +149,7 @@ object FolderDetailRepository {
|
|||
label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB",
|
||||
typeLabel = "TMDB",
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
type = type,
|
||||
catalogId = tmdbCatalogId(source),
|
||||
supportsPagination = source.tmdbSourceType !in setOf(
|
||||
|
|
@ -172,6 +177,7 @@ object FolderDetailRepository {
|
|||
label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
type = type,
|
||||
catalogId = traktCatalogId(source),
|
||||
supportsPagination = true,
|
||||
|
|
@ -179,7 +185,7 @@ object FolderDetailRepository {
|
|||
),
|
||||
)
|
||||
} else {
|
||||
val catalogSource = source.addonCatalogSource() ?: return@forEach
|
||||
val catalogSource = source.addonCatalogSource() ?: return@forEachIndexed
|
||||
val resolvedCatalog = addons.findCollectionCatalog(catalogSource)
|
||||
val addon = resolvedCatalog?.addon
|
||||
val catalog = resolvedCatalog?.catalog
|
||||
|
|
@ -191,6 +197,7 @@ object FolderDetailRepository {
|
|||
label = "$label ($typeLabel)$genreSuffix",
|
||||
typeLabel = typeLabel,
|
||||
source = source,
|
||||
sourceKey = source.catalogRouteKey(),
|
||||
manifestUrl = addon?.manifestUrl,
|
||||
type = catalogSource.type,
|
||||
catalogId = catalogSource.catalogId,
|
||||
|
|
@ -298,6 +305,7 @@ object FolderDetailRepository {
|
|||
isLoading = true,
|
||||
isLoadingMore = false,
|
||||
nextSkip = null,
|
||||
consecutiveDuplicatePages = 0,
|
||||
error = null,
|
||||
)
|
||||
} else {
|
||||
|
|
@ -340,12 +348,20 @@ object FolderDetailRepository {
|
|||
}
|
||||
val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE
|
||||
val loadedNewItems = reset || mergedItems.size > tab.items.size
|
||||
val paginationState = nextCatalogPaginationState(
|
||||
supportsPagination = supportsPagination,
|
||||
requestedSkip = requestedSkip,
|
||||
page = page,
|
||||
loadedNewItems = loadedNewItems,
|
||||
consecutiveDuplicatePages = if (reset) 0 else tab.consecutiveDuplicatePages,
|
||||
)
|
||||
tab.copy(
|
||||
items = mergedItems,
|
||||
supportsPagination = supportsPagination,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null,
|
||||
nextSkip = paginationState.nextSkip,
|
||||
consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -408,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.nuvio.app.features.streams.StreamItem
|
|||
internal object DebridMagnetBuilder {
|
||||
fun fromStream(stream: StreamItem): String? {
|
||||
stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val hash = stream.p2pInfoHash ?: return null
|
||||
return buildString {
|
||||
append("magnet:?xt=urn:btih:")
|
||||
append(hash)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ fun DetailFloatingHeader(
|
|||
.padding(horizontal = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (meta.logo != null && !logoLoadError) {
|
||||
if (!meta.logo.isNullOrBlank() && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue