feat: Implement PlayerLaunch and PlayerLaunchStore for managing player launch data; update MainAppContent to utilize new structures

This commit is contained in:
tapframe 2026-03-29 11:41:18 +05:30
parent e139a39b11
commit 258538ac8b
7 changed files with 113 additions and 34 deletions

View file

@ -68,6 +68,8 @@ import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.LibraryScreen
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.player.PlayerLaunch
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.profiles.NuvioProfile
@ -428,8 +430,8 @@ private fun MainAppContent(
onStreamSelected = { stream ->
val sourceUrl = stream.directPlaybackUrl
if (sourceUrl != null) {
navController.navigate(
PlayerRoute(
val launchId = PlayerLaunchStore.put(
PlayerLaunch(
title = route.title,
sourceUrl = sourceUrl,
logo = route.logo,
@ -450,6 +452,9 @@ private fun MainAppContent(
initialPositionMs = route.resumePositionMs ?: 0L,
)
)
navController.navigate(
PlayerRoute(launchId = launchId)
)
}
},
onBack = {
@ -461,26 +466,37 @@ private fun MainAppContent(
}
composable<PlayerRoute> { backStackEntry ->
val route = backStackEntry.toRoute<PlayerRoute>()
val launch = remember(route.launchId) { PlayerLaunchStore.get(route.launchId) }
if (launch == null) {
LaunchedEffect(route.launchId) {
navController.popBackStack()
}
Box(modifier = Modifier.fillMaxSize())
return@composable
}
PlayerScreen(
title = route.title,
sourceUrl = route.sourceUrl,
logo = route.logo,
poster = route.poster,
background = route.background,
seasonNumber = route.seasonNumber,
episodeNumber = route.episodeNumber,
episodeTitle = route.episodeTitle,
episodeThumbnail = route.episodeThumbnail,
streamTitle = route.streamTitle,
streamSubtitle = route.streamSubtitle,
providerName = route.providerName,
providerAddonId = route.providerAddonId,
contentType = route.contentType,
videoId = route.videoId,
parentMetaId = route.parentMetaId,
parentMetaType = route.parentMetaType,
initialPositionMs = route.initialPositionMs,
onBack = { navController.popBackStack() },
title = launch.title,
sourceUrl = launch.sourceUrl,
logo = launch.logo,
poster = launch.poster,
background = launch.background,
seasonNumber = launch.seasonNumber,
episodeNumber = launch.episodeNumber,
episodeTitle = launch.episodeTitle,
episodeThumbnail = launch.episodeThumbnail,
streamTitle = launch.streamTitle,
streamSubtitle = launch.streamSubtitle,
providerName = launch.providerName,
providerAddonId = launch.providerAddonId,
contentType = launch.contentType,
videoId = launch.videoId,
parentMetaId = launch.parentMetaId,
parentMetaType = launch.parentMetaType,
initialPositionMs = launch.initialPositionMs,
onBack = {
PlayerLaunchStore.remove(route.launchId)
navController.popBackStack()
},
modifier = Modifier.fillMaxSize(),
)
}

View file

@ -2,20 +2,22 @@ package com.nuvio.app.features.details
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object MetaDetailsParser {
private val json = Json { ignoreUnknownKeys = true }
fun parse(payload: String): MetaDetails {
val root = json.parseToJsonElement(payload).jsonObject
val meta = root["meta"]?.jsonObject ?: error("Missing 'meta' in response")
val root = json.parseToJsonElement(payload).asJsonObjectOrNull()
?: error("Expected top-level JSON object in response")
val meta = root["meta"].asJsonObjectOrNull()
?: error("Missing or invalid 'meta' object in response")
val links = meta.links()
return MetaDetails(
@ -190,3 +192,5 @@ internal object MetaDetailsParser {
)
}
}
private fun JsonElement?.asJsonObjectOrNull(): JsonObject? = this as? JsonObject

View file

@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable
@Serializable
data class PlayerRoute(
val launchId: Long,
)
data class PlayerLaunch(
val title: String,
val sourceUrl: String,
val logo: String? = null,
@ -24,6 +28,23 @@ data class PlayerRoute(
val initialPositionMs: Long = 0L,
)
object PlayerLaunchStore {
private var nextLaunchId = 1L
private val launches = mutableMapOf<Long, PlayerLaunch>()
fun put(launch: PlayerLaunch): Long {
val launchId = nextLaunchId++
launches[launchId] = launch
return launchId
}
fun get(launchId: Long): PlayerLaunch? = launches[launchId]
fun remove(launchId: Long) {
launches.remove(launchId)
}
}
enum class PlayerResizeMode {
Fit,
Fill,

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.details
import kotlin.test.Test
import kotlin.test.assertFailsWith
class MetaDetailsParserTest {
@Test
fun `parse rejects null meta object without json object cast crash`() {
assertFailsWith<IllegalStateException> {
MetaDetailsParser.parse("""{"meta":null}""")
}
}
}

View file

@ -0,0 +1,28 @@
package com.nuvio.app.features.player
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PlayerLaunchStoreTest {
@Test
fun storesAndRemovesLaunchesById() {
val launch = PlayerLaunch(
title = "Title",
sourceUrl = "https://example.com/video.m3u8?token=a/b:c",
streamTitle = "Source",
providerName = "Provider",
parentMetaId = "tt1234567",
parentMetaType = "movie",
)
val launchId = PlayerLaunchStore.put(launch)
assertEquals(launch, PlayerLaunchStore.get(launchId))
PlayerLaunchStore.remove(launchId)
assertNull(PlayerLaunchStore.get(launchId))
}
}

View file

@ -156,7 +156,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\nif [ -z \"$JAVA_HOME\" ] && [ -x \"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home/bin/java\" ]; then\n export JAVA_HOME=\"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home\"\nfi\nif [ -n \"$JAVA_HOME\" ]; then\n export PATH=\"$JAVA_HOME/bin:/opt/homebrew/bin:$PATH\"\nelse\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n";
};
/* End PBXShellScriptBuildPhase section */

View file

@ -60,15 +60,6 @@ final class OrientationLockCoordinator {
print("[OrientationLockCoordinator] Geometry update failed: \(error.localizedDescription)")
}
}
}
if forceRotate {
UIDevice.current.setValue(preferredLandscapeOrientation.rawValue, forKey: "orientation")
} else if UIDevice.current.orientation.isPortrait {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
}
if #available(iOS 16.0, *) {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
@ -76,6 +67,11 @@ final class OrientationLockCoordinator {
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
} else {
if forceRotate {
UIDevice.current.setValue(preferredLandscapeOrientation.rawValue, forKey: "orientation")
} else if UIDevice.current.orientation.isPortrait {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
}
UIViewController.attemptRotationToDeviceOrientation()
}
}