diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index c381ceafa..f727cf08d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { backStackEntry -> val route = backStackEntry.toRoute() + 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(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 7d3b74e2b..dba082bf9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 2c74c79b0..e522606bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -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() + + 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, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt new file mode 100644 index 000000000..98f01a9a1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -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 { + MetaDetailsParser.parse("""{"meta":null}""") + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt new file mode 100644 index 000000000..4128f45cd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerLaunchStoreTest.kt @@ -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)) + } +} \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ff0b5cfee..c19bde737 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -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 */ diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index b06c58331..8f72fe51b 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -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() } }