diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0062f675..76d1ee09 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -132,7 +132,6 @@ kotlin { implementation(project(":firebase")) implementation(project(":database")) implementation(project(":github")) - // implementation(project(":k2k")) } val androidMain by getting { diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 0550aa2e..d39aa67c 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -2,14 +2,12 @@ + + - - diff --git a/composeApp/src/androidMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/TopControls.kt b/composeApp/src/androidMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/TopControls.kt index 072e0ec3..536bfea5 100644 --- a/composeApp/src/androidMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/TopControls.kt +++ b/composeApp/src/androidMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/TopControls.kt @@ -44,6 +44,7 @@ import dev.datlag.burningseries.common.icon import dev.datlag.burningseries.common.rememberIsTv import dev.datlag.burningseries.composeapp.generated.resources.Res import dev.datlag.burningseries.composeapp.generated.resources.cast +import dev.datlag.burningseries.other.K2Kast import dev.datlag.kast.ConnectionState import dev.datlag.kast.DeviceType import dev.datlag.kast.Kast @@ -148,6 +149,7 @@ fun TopControls( Kast.unselect(UnselectReason.disconnected) Kast.Android.passiveDiscovery() } else { + K2Kast.disconnect() Kast.select(device) } } diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 0de7f961..3ebd0a64 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -74,4 +74,9 @@ Bild in Bild ist hier nicht verfügbar. Open-Source Lizenzen Das ist eine Liste von (allen) Bibliotheken, die in diesem Projekt verwendet werden und deren Lizenzen + Stream wird geladen, bitte warten + Verbindungscode + Diese Funktion ist experimentell und funktioniert möglicherweise nicht wie erwartet, bitte verwende Chromecasting wenn verfügbar. + Beachte dass einige Geräte diese Funktion nicht unterstützen. + Normale Ansicht \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c03f836f..2b913def 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -74,4 +74,9 @@ Picture in Picture is not available in this screen. Open-Source Licenses This is a list of (all) libraries used in this project and it's licenses + Loading Stream, please wait + Connection Code + This feature is experimental and may not work as expected, please use Chromecasting if possible. + Please note that some devices do not support this feature. + Default View \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/App.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/App.kt index 7b45137f..6eca1815 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/App.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/App.kt @@ -32,6 +32,8 @@ import dev.datlag.burningseries.ui.theme.dynamicDark import dev.datlag.burningseries.ui.theme.dynamicLight import dev.datlag.tooling.Platform import dev.datlag.tooling.compose.platform.CombinedPlatformMaterialTheme +import dev.datlag.tooling.compose.platform.PlatformSurface +import dev.datlag.tooling.compose.platform.colorScheme import dev.datlag.tooling.compose.platform.rememberIsTv import dev.datlag.tooling.compose.toTypography import kotlinx.coroutines.flow.MutableStateFlow @@ -57,10 +59,10 @@ internal fun App( colorScheme = if (systemDarkTheme) Colors.dynamicDark() else Colors.dynamicLight(), typography = ManropeFontFamily().toTypography() ) { - Surface( + PlatformSurface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground + containerColor = Platform.colorScheme().background, + contentColor = Platform.colorScheme().onBackground ) { content() } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/K2Kast.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/K2Kast.kt new file mode 100644 index 00000000..9308848a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/K2Kast.kt @@ -0,0 +1,212 @@ +package dev.datlag.burningseries.other + +import dev.datlag.burningseries.model.Series +import dev.datlag.burningseries.model.SeriesData +import dev.datlag.burningseries.model.serializer.SerializableImmutableSet +import dev.datlag.k2k.Host +import dev.datlag.k2k.connect.Connection +import dev.datlag.k2k.connect.connection +import dev.datlag.k2k.discover.Discovery +import dev.datlag.k2k.discover.discovery +import dev.datlag.nanoid.NanoIdUtils +import dev.datlag.skeo.DirectLink +import dev.datlag.tooling.async.suspendCatching +import dev.datlag.tooling.compose.ioDispatcher +import io.github.aakira.napier.Napier +import kotlinx.collections.immutable.ImmutableCollection +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf + +data object K2Kast : AutoCloseable { + + private lateinit var showClient: Discovery + private lateinit var searchClient: Discovery + + private lateinit var connection: Connection + + private var scope: CoroutineScope? = null + private val connectedHost = MutableStateFlow(null) + + val code: String by lazy { + NanoIdUtils.randomNanoId( + alphabet = "0123456789".toCharArray(), + size = 6 + ) + } + + @OptIn(DelicateCoroutinesApi::class) + val devices: StateFlow> + get() = if (::searchClient.isInitialized) { + combine( + connectedHost, + searchClient.peers + ) { selected, all -> + all.map { + Device( + host = it, + selected = selected == it + ) + }.toImmutableSet() + }.stateIn( + scope = scope ?: (GlobalScope + ioDispatcher()), + started = SharingStarted.WhileSubscribed(), + initialValue = searchClient.peers.value.map(::Device).toImmutableSet() + ) + } else { + MutableStateFlow(persistentSetOf()) + } + + val connectedDevice: Device? + get() = devices.value.firstOrNull { it.selected } + + fun initialize(scope: CoroutineScope) { + this.scope = scope + + initializeShow(scope) + initializeSearch(scope) + initializeConnection(scope) + } + + private fun initializeShow(scope: CoroutineScope) { + if (::showClient.isInitialized) { + return + } + + showClient = scope.discovery { + setShowTimeout(0L) + setPort(7330) + } + } + + private fun initializeSearch(scope: CoroutineScope) { + if (::searchClient.isInitialized) { + return + } + + searchClient = scope.discovery { + setSearchTimeout(0L) + setPort(7330) + } + } + + private fun initializeConnection(scope: CoroutineScope) { + if (::connection.isInitialized) { + return + } + + connection = scope.connection { + setPort(7332) + noDelay() + } + } + + fun show(name: String) { + showClient.show(name = name, filterMatch = code) + } + + fun hide() { + showClient.hide() + } + + fun search(code: String?) { + if (code.isNullOrBlank() || code.length != 6) { + searchClient.lose() + return + } + + searchClient.search( + filter = "^$code$".toRegex() + ) + } + + fun lose() { + searchClient.lose() + } + + fun connect(host: Host) { + connectedHost.update { host } + } + + fun connect(device: Device) = connect(device.host) + + fun disconnect() { + connectedHost.update { null } + } + + fun receive(listener: suspend (ByteArray) -> Unit) = connection.receive(listener) + + suspend fun send(byteArray: ByteArray, host: Host? = connectedDevice?.host) { + if (host == null) { + return + } + + suspendCatching { + connection.sendNow(byteArray, host) + } + } + suspend fun send(byteArray: ByteArray, device: Device? = connectedDevice) = send(byteArray, device?.host) + + suspend fun watch( + episode: Series.Episode, + host: Host? = connectedDevice?.host + ) = send( + byteArray = episode.href.encodeToByteArray(), + host = host + ) + + suspend fun watch( + episode: Series.Episode, + device: Device? = connectedDevice + ) = watch( + episode = episode, + host = device?.host + ) + + override fun close() { + this.scope?.cancel() + this.scope = null + + closeShow() + closeSearch() + } + + private fun closeShow() { + if (!::showClient.isInitialized) { + return + } + + showClient.close() + } + + private fun closeSearch() { + if (!::searchClient.isInitialized) { + return + } + + searchClient.close() + } + + @Serializable + data class Device( + val host: Host, + val name: String = host.name, + val selected: Boolean = connectedHost.value == host + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/StateSaver.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/StateSaver.kt index 43b3ff46..5ae385a7 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/StateSaver.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/other/StateSaver.kt @@ -1,5 +1,8 @@ package dev.datlag.burningseries.other +import kotlinx.coroutines.flow.MutableStateFlow + data object StateSaver { var sekretLibraryLoaded: Boolean = false + val defaultHome = MutableStateFlow(false) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/K2KastComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/K2KastComponent.kt new file mode 100644 index 00000000..8cd7f83b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/K2KastComponent.kt @@ -0,0 +1,6 @@ +package dev.datlag.burningseries.ui.navigation + +interface K2KastComponent : Component { + + suspend fun k2kastLoad(href: String?) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/RootComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/RootComponent.kt index ad43f6b6..39aa6ad4 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/RootComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/RootComponent.kt @@ -1,6 +1,7 @@ package dev.datlag.burningseries.ui.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children @@ -8,18 +9,30 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.active import com.arkivanov.decompose.router.stack.bringToFront import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.replaceAll import com.arkivanov.decompose.router.stack.replaceCurrent +import com.arkivanov.essenty.lifecycle.doOnDestroy +import dev.datlag.burningseries.model.BSUtil import dev.datlag.burningseries.model.SeriesData +import dev.datlag.burningseries.network.BurningSeries +import dev.datlag.burningseries.network.common.dispatchIgnoreCollect +import dev.datlag.burningseries.network.state.EpisodeAction +import dev.datlag.burningseries.other.K2Kast import dev.datlag.burningseries.settings.Settings import dev.datlag.burningseries.ui.navigation.screen.activate.ActivateScreenComponent import dev.datlag.burningseries.ui.navigation.screen.home.HomeScreenComponent import dev.datlag.burningseries.ui.navigation.screen.medium.MediumScreenComponent import dev.datlag.burningseries.ui.navigation.screen.video.VideoScreenComponent import dev.datlag.burningseries.ui.navigation.screen.welcome.WelcomeScreenComponent +import dev.datlag.tooling.Platform +import dev.datlag.tooling.compose.platform.rememberIsTv +import dev.datlag.tooling.decompose.ioScope +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.firstOrNull @@ -84,6 +97,9 @@ class RootComponent( syncId = rootConfig.syncId, onMedium = { data, lang -> navigation.bringToFront(RootConfig.Medium(data, lang)) + }, + onWatch = { series, episode, streams -> + navigation.bringToFront(RootConfig.Video(series, episode, streams.toImmutableSet())) } ) is RootConfig.Medium -> MediumScreenComponent( @@ -130,10 +146,26 @@ class RootComponent( override val handlesPIP: Boolean = true + private val deviceName by instance("DEVICE_NAME") + + init { + K2Kast.initialize(ioScope()) + + doOnDestroy { + K2Kast.close() + } + } + @OptIn(ExperimentalDecomposeApi::class) @Composable override fun render() { onRender { + if (Platform.rememberIsTv()) { + LaunchedEffect(Unit) { + showK2Kast() + } + } + Children( stack = stack, animation = predictiveBackAnimation( @@ -156,4 +188,14 @@ class RootComponent( fun onSeries(href: String) { navigation.bringToFront(RootConfig.Medium(SeriesData.fromHref(href), null)) } + + private fun showK2Kast() { + K2Kast.show(name = deviceName) + + K2Kast.receive { bytes -> + val episodeHref = BSUtil.matchingUrl(bytes.decodeToString(), null)?.ifBlank { null } + + (stack.active.instance as? K2KastComponent)?.k2kastLoad(episodeHref) + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeComponent.kt index 9a84d307..563218b0 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeComponent.kt @@ -6,17 +6,21 @@ import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value import dev.datlag.burningseries.database.ExtendedSeries import dev.datlag.burningseries.github.model.UserAndRelease +import dev.datlag.burningseries.model.Series import dev.datlag.burningseries.model.SeriesData +import dev.datlag.burningseries.network.state.EpisodeState import dev.datlag.burningseries.network.state.HomeState import dev.datlag.burningseries.network.state.SearchState import dev.datlag.burningseries.settings.model.Language import dev.datlag.burningseries.ui.navigation.Component import dev.datlag.burningseries.ui.navigation.DialogComponent +import dev.datlag.burningseries.ui.navigation.K2KastComponent +import dev.datlag.skeo.DirectLink import kotlinx.collections.immutable.ImmutableCollection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -interface HomeComponent : Component { +interface HomeComponent : K2KastComponent { val home: StateFlow val search: StateFlow val showFavorites: StateFlow @@ -38,6 +42,10 @@ interface HomeComponent : Component { fun release(release: UserAndRelease.Release) fun showQrCode() + val k2KastState: Flow + val k2KastSeries: StateFlow + suspend fun watch(episode: Series.Episode, streams: ImmutableCollection) + data class GenreFilterInfo( val containerColor: Color, val labelColor: Color, diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreen.kt index c4bc495d..41b6d03b 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreen.kt @@ -9,9 +9,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import com.arkivanov.decompose.extensions.compose.subscribeAsState +import dev.datlag.burningseries.other.StateSaver import dev.datlag.burningseries.ui.custom.AndroidFixWindowSize import dev.datlag.burningseries.ui.navigation.screen.home.component.CompactScreen import dev.datlag.burningseries.ui.navigation.screen.home.component.HomeSearchBar +import dev.datlag.burningseries.ui.navigation.screen.home.component.TvScreen +import dev.datlag.tooling.Platform +import dev.datlag.tooling.compose.platform.rememberIsTv import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle @OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalMaterial3Api::class) @@ -21,6 +25,7 @@ fun HomeScreen(component: HomeComponent) { val dialogState by component.dialog.subscribeAsState() val release by component.release.collectAsStateWithLifecycle(null) val displayRelease by component.displayRelease.collectAsStateWithLifecycle() + val defaultView by StateSaver.defaultHome.collectAsStateWithLifecycle() LaunchedEffect(release, displayRelease) { if (displayRelease) { @@ -30,18 +35,22 @@ fun HomeScreen(component: HomeComponent) { dialogState.child?.instance?.render() - Scaffold( - topBar = { - HomeSearchBar(component) - }, - ) { padding -> - val state by component.home.collectAsStateWithLifecycle() + if (!defaultView && Platform.rememberIsTv()) { + TvScreen(component) + } else { + Scaffold( + topBar = { + HomeSearchBar(component) + }, + ) { padding -> + val state by component.home.collectAsStateWithLifecycle() - CompactScreen( - state = state, - padding = padding, - component = component - ) + CompactScreen( + state = state, + padding = padding, + component = component + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreenComponent.kt index be91a7e8..01eddb64 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/HomeScreenComponent.kt @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.doOnDestroy import dev.chrisbanes.haze.HazeState import dev.datlag.burningseries.LocalHaze import dev.datlag.burningseries.database.BurningSeries @@ -18,13 +19,18 @@ import dev.datlag.burningseries.database.common.favoritesSeries import dev.datlag.burningseries.database.common.favoritesSeriesOneShot import dev.datlag.burningseries.database.common.seriesFullHref import dev.datlag.burningseries.github.model.UserAndRelease +import dev.datlag.burningseries.model.BSUtil +import dev.datlag.burningseries.model.Series import dev.datlag.burningseries.model.SeriesData +import dev.datlag.burningseries.network.EpisodeStateMachine import dev.datlag.burningseries.network.HomeStateMachine import dev.datlag.burningseries.network.SearchStateMachine import dev.datlag.burningseries.network.common.dispatchIgnoreCollect +import dev.datlag.burningseries.network.state.EpisodeAction import dev.datlag.burningseries.network.state.HomeState import dev.datlag.burningseries.network.state.SearchAction import dev.datlag.burningseries.network.state.SearchState +import dev.datlag.burningseries.other.K2Kast import dev.datlag.burningseries.other.UserHelper import dev.datlag.burningseries.settings.Settings import dev.datlag.burningseries.settings.model.Language @@ -34,25 +40,33 @@ import dev.datlag.burningseries.ui.navigation.screen.home.dialog.qrcode.QrCodeDi import dev.datlag.burningseries.ui.navigation.screen.home.dialog.release.ReleaseDialogComponent import dev.datlag.burningseries.ui.navigation.screen.home.dialog.settings.SettingsDialogComponent import dev.datlag.burningseries.ui.navigation.screen.home.dialog.sync.SyncDialogComponent +import dev.datlag.skeo.DirectLink import dev.datlag.tooling.compose.ioDispatcher +import dev.datlag.tooling.compose.withIOContext +import dev.datlag.tooling.compose.withMainContext import dev.datlag.tooling.decompose.ioScope +import io.github.aakira.napier.Napier +import io.ktor.client.HttpClient import kotlinx.collections.immutable.ImmutableCollection import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import org.kodein.di.DI import org.kodein.di.instance -import org.kodein.di.instanceOrNull +import dev.datlag.burningseries.network.BurningSeries as BSLoader class HomeScreenComponent( componentContext: ComponentContext, override val di: DI, private val syncId: String?, - private val onMedium: (SeriesData, Language?) -> Unit + private val onMedium: (SeriesData, Language?) -> Unit, + private val onWatch: (Series, Series.Episode, ImmutableCollection) -> Unit ): HomeComponent, ComponentContext by componentContext { private val homeStateMachine by instance() @@ -134,6 +148,17 @@ class HomeScreenComponent( } } + private val httpClient by instance() + private val episodeStateMachine by instance() + override val k2KastState = episodeStateMachine.state + override val k2KastSeries = MutableStateFlow(null) + + init { + doOnDestroy { + K2Kast.hide() + } + } + @Composable override fun render() { val haze = remember { HazeState() } @@ -185,4 +210,31 @@ class HomeScreenComponent( override fun showQrCode() { dialogNavigation.activate(DialogConfig.QrCode) } + + override suspend fun k2kastLoad(href: String?) = withIOContext { + if (!href.isNullOrBlank()) { + val series = k2KastSeries.updateAndGet { + BSLoader.series(client = httpClient, href) + } + val episode = series?.episodes?.firstOrNull { it.href.equals(href, ignoreCase = true) } + + if (episode != null) { + episodeStateMachine.dispatchIgnoreCollect(EpisodeAction.LoadNonSuccess(episode)) + } else { + k2KastSeries.update { null } + } + } + } + + override suspend fun watch(episode: Series.Episode, streams: ImmutableCollection) { + withIOContext { + episodeStateMachine.dispatchIgnoreCollect(EpisodeAction.Clear) + + k2KastSeries.getAndUpdate { null }?.let { + withMainContext { + onWatch(it, episode, streams) + } + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/component/TvScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/component/TvScreen.kt new file mode 100644 index 00000000..bb0c4e5e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/home/component/TvScreen.kt @@ -0,0 +1,155 @@ +package dev.datlag.burningseries.ui.navigation.screen.home.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cast +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.datlag.burningseries.composeapp.generated.resources.Res +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_connection_code +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_default_home +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_experimental +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_loading +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_support +import dev.datlag.burningseries.network.state.EpisodeState +import dev.datlag.burningseries.other.K2Kast +import dev.datlag.burningseries.other.StateSaver +import dev.datlag.burningseries.ui.navigation.screen.home.HomeComponent +import dev.datlag.tooling.Platform +import dev.datlag.tooling.compose.platform.PlatformButton +import dev.datlag.tooling.compose.platform.PlatformIcon +import dev.datlag.tooling.compose.platform.PlatformText +import dev.datlag.tooling.compose.platform.localContentColor +import dev.datlag.tooling.compose.platform.typography +import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.datlag.tooling.safeSubString +import kotlinx.coroutines.flow.update +import org.jetbrains.compose.resources.stringResource + +@Composable +fun TvScreen( + component: HomeComponent +) { + val state by component.k2KastState.collectAsStateWithLifecycle(EpisodeState.None) + val series by component.k2KastSeries.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + when (val current = state) { + is EpisodeState.SuccessStream -> { + component.watch(current.episode, current.results) + } + else -> { } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (state.isTvLoading) { + item { + Box( + modifier = Modifier.size(96.dp), + contentAlignment = Alignment.Center + ) { + AsyncImage( + modifier = Modifier + .matchParentSize() + .padding( + ProgressIndicatorDefaults.CircularStrokeWidth.times(2) + ) + .clip(CircleShape), + model = series?.coverHref, + contentDescription = null, + contentScale = ContentScale.Crop + ) + CircularProgressIndicator( + modifier = Modifier.matchParentSize(), + color = Platform.localContentColor() + ) + } + } + item { + PlatformText( + text = series?.mainTitle ?: stringResource(Res.string.k2kast_loading), + textAlign = TextAlign.Center + ) + } + } else { + item { + PlatformIcon( + modifier = Modifier.size(64.dp), + imageVector = Icons.Rounded.Cast, + contentDescription = null + ) + } + item { + PlatformText( + text = stringResource(Res.string.k2kast_connection_code), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ) + } + item { + PlatformText( + text = K2Kast.code.safeSubString(0, 3) + " " + K2Kast.code.safeSubString(3, 6), + textAlign = TextAlign.Center, + style = Platform.typography().headlineLarge + ) + } + item { + PlatformText( + modifier = Modifier.fillParentMaxWidth(0.7F).padding(top = 32.dp), + text = stringResource(Res.string.k2kast_experimental), + textAlign = TextAlign.Center + ) + } + item { + PlatformText( + modifier = Modifier.fillParentMaxWidth(0.7F), + text = stringResource(Res.string.k2kast_support), + textAlign = TextAlign.Center + ) + } + item { + PlatformButton( + onClick = { + K2Kast.hide() + StateSaver.defaultHome.update { true } + } + ) { + PlatformIcon( + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Rounded.Home, + contentDescription = null + ) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + PlatformText( + text = stringResource(Res.string.k2kast_default_home) + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumComponent.kt index b5afb649..537d4f7c 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumComponent.kt @@ -1,5 +1,11 @@ package dev.datlag.burningseries.ui.navigation.screen.medium +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Speaker +import androidx.compose.material.icons.rounded.Tv +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.ui.graphics.vector.ImageVector import com.arkivanov.decompose.router.slot.ChildSlot import com.arkivanov.decompose.value.Value import dev.datlag.burningseries.database.CombinedEpisode @@ -7,8 +13,13 @@ import dev.datlag.burningseries.model.Series import dev.datlag.burningseries.model.SeriesData import dev.datlag.burningseries.network.state.EpisodeState import dev.datlag.burningseries.network.state.SeriesState +import dev.datlag.burningseries.other.K2Kast import dev.datlag.burningseries.ui.navigation.Component import dev.datlag.burningseries.ui.navigation.DialogComponent +import dev.datlag.kast.Device +import dev.datlag.kast.DeviceType +import dev.datlag.kast.Kast +import dev.datlag.kast.UnselectReason import dev.datlag.skeo.DirectLink import kotlinx.collections.immutable.ImmutableCollection import kotlinx.collections.immutable.ImmutableSet @@ -57,4 +68,48 @@ interface MediumComponent : Component { episode: Series.Episode, streams: ImmutableCollection ) + + sealed interface Device { + val icon: ImageVector + val name: String + val selected: Boolean + + fun select() + + data class Chrome( + private val device: dev.datlag.kast.Device, + override val icon: ImageVector = when (device.type) { + is DeviceType.TV -> Icons.Rounded.Tv + is DeviceType.SPEAKER -> Icons.Rounded.Speaker + else -> Icons.Rounded.Devices + }, + override val name: String = device.name, + override val selected: Boolean = device.isSelected + ) : Device { + override fun select() { + if (selected) { + Kast.unselect(UnselectReason.disconnected) + } else { + K2Kast.disconnect() + Kast.select(device) + } + } + } + + data class K2K( + private val device: K2Kast.Device, + override val icon: ImageVector = Icons.Rounded.Warning, + override val name: String = "${device.name} (Experimental)", + override val selected: Boolean = device.selected + ) : Device { + override fun select() { + if (selected) { + K2Kast.disconnect() + } else { + Kast.unselect(UnselectReason.disconnected) + K2Kast.connect(device) + } + } + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumScreenComponent.kt index dabc976c..c7e4c7ef 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/MediumScreenComponent.kt @@ -36,6 +36,7 @@ import dev.datlag.burningseries.network.common.dispatchIgnoreCollect import dev.datlag.burningseries.network.state.EpisodeAction import dev.datlag.burningseries.network.state.EpisodeState import dev.datlag.burningseries.network.state.SeriesState +import dev.datlag.burningseries.other.K2Kast import dev.datlag.burningseries.other.UserHelper import dev.datlag.burningseries.settings.model.Language import dev.datlag.burningseries.ui.navigation.DialogComponent @@ -43,6 +44,7 @@ import dev.datlag.burningseries.ui.navigation.screen.medium.dialog.activate.Acti import dev.datlag.burningseries.ui.navigation.screen.medium.dialog.sponsor.SponsorDialogComponent import dev.datlag.skeo.DirectLink import dev.datlag.tooling.compose.ioDispatcher +import dev.datlag.tooling.compose.launchIO import dev.datlag.tooling.compose.withIOContext import dev.datlag.tooling.compose.withMainContext import dev.datlag.tooling.decompose.ioScope @@ -247,7 +249,12 @@ class MediumScreenComponent( override fun episode(episode: Series.Episode) { launchIO { - episodeStateMachine.dispatchIgnoreCollect(EpisodeAction.Load(episode)) + val device = K2Kast.connectedDevice + if (device != null) { + K2Kast.watch(episode, device) + } else { + episodeStateMachine.dispatchIgnoreCollect(EpisodeAction.Load(episode)) + } } } @@ -258,6 +265,7 @@ class MediumScreenComponent( ) { launchIO { episodeStateMachine.dispatchIgnoreCollect(EpisodeAction.Clear) + withMainContext { onWatch(series, episode, streams) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/component/Toolbar.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/component/Toolbar.kt index 176ed887..5861f0f9 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/component/Toolbar.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/medium/component/Toolbar.kt @@ -11,6 +11,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ArrowBackIosNew import androidx.compose.material.icons.rounded.Cast @@ -23,11 +25,14 @@ import androidx.compose.material.icons.rounded.Tv import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,6 +42,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.maxkeppeker.sheets.core.models.base.Header @@ -58,7 +66,10 @@ import dev.datlag.burningseries.common.rememberIsTv import dev.datlag.burningseries.composeapp.generated.resources.Res import dev.datlag.burningseries.composeapp.generated.resources.cast import dev.datlag.burningseries.composeapp.generated.resources.casting_not_supported +import dev.datlag.burningseries.composeapp.generated.resources.k2kast_connection_code +import dev.datlag.burningseries.composeapp.generated.resources.search import dev.datlag.burningseries.model.Series +import dev.datlag.burningseries.other.K2Kast import dev.datlag.burningseries.ui.navigation.screen.medium.MediumComponent import dev.datlag.kast.ConnectionState import dev.datlag.kast.DeviceType @@ -71,6 +82,8 @@ import dev.datlag.tooling.compose.platform.PlatformText import dev.datlag.tooling.compose.platform.ProvideNonTvContentColor import dev.datlag.tooling.compose.platform.ProvideNonTvTextStyle import dev.datlag.tooling.decompose.lifecycle.collectAsStateWithLifecycle +import dev.datlag.tooling.safeSubString +import dev.datlag.tooling.setFrom import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -128,33 +141,34 @@ internal fun Toolbar( if (!Platform.rememberIsTv()) { val kastDevices by Kast.allAvailableDevices.collectAsStateWithLifecycle() + val k2kastDevices by K2Kast.devices.collectAsStateWithLifecycle() + val combinedDevices = remember(kastDevices, k2kastDevices) { + setFrom( + k2kastDevices.map(MediumComponent.Device::K2K), + kastDevices.map(MediumComponent.Device::Chrome) + ) + } + val kastState by Kast.connectionState.collectAsStateWithLifecycle() + val k2kastConnected = remember(k2kastDevices) { k2kastDevices.any { it.selected } } val kastDialog = rememberUseCaseState() OptionDialog( state = kastDialog, selection = OptionSelection.Single( - options = kastDevices.map { device -> + options = combinedDevices.map { device -> Option( icon = IconSource( - imageVector = when (device.type) { - is DeviceType.TV -> Icons.Rounded.Tv - is DeviceType.SPEAKER -> Icons.Rounded.Speaker - else -> Icons.Rounded.Devices - } + imageVector = device.icon ), titleText = device.name, - selected = device.isSelected + selected = device.selected ) }, onSelectOption = { option, _ -> - val device = kastDevices.toList()[option] + val device = combinedDevices.toList()[option] - if (device.isSelected) { - Kast.unselect(UnselectReason.disconnected) - } else { - Kast.select(device) - } + device.select() } ), config = OptionConfig( @@ -166,17 +180,53 @@ internal fun Toolbar( ), title = stringResource(Res.string.cast) ), - body = if (Kast.isSupported) { - null - } else { - OptionBody.Default( - bodyText = stringResource(Res.string.casting_not_supported) - ) + body = OptionBody.Custom { + var value by remember { mutableStateOf("") } + + LaunchedEffect(value) { + K2Kast.search(value) + } + + Column( + modifier = Modifier.padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = value, + onValueChange = { + value = it.replace("\\D*".toRegex(), "").trim().safeSubString(0, 6) + }, + placeholder = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.k2kast_connection_code), + textAlign = TextAlign.Center + ) + }, + shape = MaterialTheme.shapes.medium, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Search + ), + singleLine = true, + maxLines = 1 + ) + + if (!Kast.isSupported) { + Text( + text = stringResource(Res.string.casting_not_supported), + textAlign = TextAlign.Center + ) + } + } } ) - when (kastState) { - is ConnectionState.CONNECTED -> { + when { + kastState is ConnectionState.CONNECTING || kastState is ConnectionState.CONNECTED -> { IconButton( onClick = { Kast.unselect(UnselectReason.disconnected) @@ -188,14 +238,14 @@ internal fun Toolbar( ) } } - is ConnectionState.CONNECTING -> { + k2kastConnected -> { IconButton( onClick = { - Kast.unselect(UnselectReason.disconnected) + K2Kast.disconnect() } ) { Icon( - imageVector = kastState.icon, + imageVector = Icons.Rounded.CastConnected, contentDescription = null ) } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/VideoScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/VideoScreenComponent.kt index 97f52925..fa104d82 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/VideoScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/burningseries/ui/navigation/screen/video/VideoScreenComponent.kt @@ -16,6 +16,7 @@ import dev.datlag.burningseries.network.EpisodeStateMachine import dev.datlag.burningseries.network.common.dispatchIgnoreCollect import dev.datlag.burningseries.network.state.EpisodeAction import dev.datlag.burningseries.network.state.EpisodeState +import dev.datlag.burningseries.other.K2Kast import dev.datlag.skeo.DirectLink import dev.datlag.tooling.compose.ioDispatcher import dev.datlag.tooling.compose.withMainContext diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8317e94..dbad9a9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ serialization = "1.7.1" skeo = "0.2.1" sqldelight = "2.0.2" splashscreen = "1.0.1" -tooling = "1.6.4" +tooling = "1.6.5" versions = "0.51.0" vlcj = "4.8.3" webview = "0.33.6" diff --git a/k2k/build.gradle.kts b/k2k/build.gradle.kts deleted file mode 100644 index ca221b35..00000000 --- a/k2k/build.gradle.kts +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - alias(libs.plugins.multiplatform) - alias(libs.plugins.serialization) -} - -kotlin { - jvm() - - applyDefaultHierarchyTemplate() - - sourceSets { - commonMain.dependencies { - api(libs.immutable) - implementation(libs.coroutines) - implementation(libs.ktor) - implementation(libs.ktor.network) - implementation(libs.ktor.network.tls) - implementation(libs.serialization.json) - implementation(libs.tooling) - } - } -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Constants.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Constants.kt deleted file mode 100644 index a43477bd..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Constants.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.datlag.burningseries.k2k - -import kotlinx.serialization.json.Json - -internal data object Constants { - val json: Json = Json { - isLenient = true - } - - const val BROADCAST_ADDRESS = "255.255.255.255" - const val BROADCAST_SOCKET = "0.0.0.0" -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Host.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Host.kt deleted file mode 100644 index ab9ab042..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/Host.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.datlag.burningseries.k2k - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlin.properties.Delegates - -@Serializable -data class Host( - @SerialName("name") val name: String, - @SerialName("filterMatch") val filterMatch: String = "" -) { - @Transient - lateinit var hostAddress: String - - var port by Delegates.notNull() -} diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.kt deleted file mode 100644 index d4c08849..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.datlag.burningseries.k2k - -import kotlinx.collections.immutable.ImmutableSet - -expect object NetInterface { - // udp, no isLoopback, broadcastAddress is not null - fun getAddresses(): ImmutableSet - fun getLocalAddress(): String -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/Connection.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/Connection.kt deleted file mode 100644 index 7df868cd..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/Connection.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.datlag.burningseries.k2k.connect - -import dev.datlag.burningseries.k2k.Host -import dev.datlag.burningseries.k2k.discover.Discovery -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlin.properties.Delegates - -class Connection private constructor( - private val port: Int, - private val scope: CoroutineScope -) { - - suspend fun send(bytes: ByteArray, peer: Host) = ConnectionClient.send(bytes, peer, port) - - fun startReceiving(listener: suspend (ByteArray) -> Unit) { - ConnectionServer.startServer(port, scope, listener) - } - - fun stopReceiving() { - ConnectionServer.stopServer() - } - - class Builder(private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) { - private var port by Delegates.notNull() - - fun setPort(port: Int) = apply { - this.port = port - } - - fun setScope(scope: CoroutineScope) = apply { - this.scope = scope - } - - fun build() = Connection(port, scope) - } -} - -fun CoroutineScope.connection(builder: Connection.Builder.() -> Unit) = Connection.Builder(this).apply(builder).build() \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionClient.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionClient.kt deleted file mode 100644 index 03dc1342..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionClient.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.datlag.burningseries.k2k.connect - -import dev.datlag.burningseries.k2k.Host -import dev.datlag.tooling.async.suspendCatching -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openWriteChannel -import kotlinx.coroutines.Dispatchers - -internal data object ConnectionClient { - suspend fun send( - bytes: ByteArray, - host: Host, - port: Int - ) { - suspendCatching { - val socketAddress = InetSocketAddress(host.hostAddress, port) - val socket = aSocket(SelectorManager(Dispatchers.IO)) - .tcp() - .connect(socketAddress) { - socketTimeout = 20000 - reuseAddress = true - } - - val writeChannel = socket.openWriteChannel(autoFlush = true) - writeChannel.writeFully(bytes, 0, bytes.size) - } - } -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionServer.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionServer.kt deleted file mode 100644 index 196e9c56..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/connect/ConnectionServer.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.datlag.burningseries.k2k.connect - -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openReadChannel -import io.ktor.utils.io.core.use -import io.ktor.utils.io.readAvailable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import dev.datlag.burningseries.k2k.NetInterface -import dev.datlag.tooling.async.suspendCatching -import kotlinx.coroutines.flow.update - -internal data object ConnectionServer { - private var socket = aSocket(SelectorManager(Dispatchers.IO)).tcp() - private var receiveJob: Job? = null - - fun startServer( - port: Int, - scope: CoroutineScope, - listener: suspend (ByteArray) -> Unit - ) { - stopServer() - - receiveJob = scope.launch(Dispatchers.IO) { - while (true) { - val socketAddress = InetSocketAddress(NetInterface.getLocalAddress(), port) - - socket.bind(socketAddress) { - reuseAddress = true - }.accept().use { boundSocket -> - suspendCatching { - val readChannel = boundSocket.openReadChannel() - val buffer = ByteArray(readChannel.availableForRead) - while (true) { - val bytesRead = readChannel.readAvailable(buffer) - if (bytesRead <= 0) { - break - } - - listener(buffer) - } - }.onFailure { - boundSocket.close() - } - } - } - } - } - - fun stopServer() { - receiveJob?.cancel() - socket = aSocket(SelectorManager(Dispatchers.IO)).tcp() - } -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/Discovery.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/Discovery.kt deleted file mode 100644 index 043ed54f..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/Discovery.kt +++ /dev/null @@ -1,149 +0,0 @@ -package dev.datlag.burningseries.k2k.discover - -import dev.datlag.burningseries.k2k.Constants -import dev.datlag.burningseries.k2k.Host -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import kotlinx.serialization.encodeToString -import kotlin.properties.Delegates -import kotlin.time.Duration - -class Discovery private constructor( - private val discoveryTimeout: Long, - private val discoveryTimeoutListener: (suspend () -> Unit)?, - private val discoverableTimeout: Long, - private val discoverableTimeoutListener: (suspend () -> Unit)?, - private val discoverPing: Long, - private val port: Int, - private val hostFilter: Regex, - private val hostIsClient: Boolean, - private val scope: CoroutineScope -) { - - private var discoverableTimer: Job? = null - private var discoveryTimer: Job? = null - - val peers: StateFlow> = DiscoveryServer.hosts - - fun makeDiscoverable(host: Host) { - val sendDataString = Constants.json.encodeToString(host) - DiscoveryClient.startBroadcasting(port, discoverPing, sendDataString.encodeToByteArray(), scope) - discoverableTimer?.cancel() - - if (discoverableTimeout > 0L) { - discoverableTimer = scope.launch(Dispatchers.IO) { - delay(discoverableTimeout) - discoverableTimeoutListener?.invoke() - stopBeingDiscoverable() - } - } - } - - @JvmOverloads - fun makeDiscoverable( - hostName: String, - filterMatch: String = "" - ) = makeDiscoverable(Host(hostName, filterMatch)) - - fun stopBeingDiscoverable() { - DiscoveryClient.stopBroadcasting() - discoverableTimer?.cancel() - } - - @JvmOverloads - fun startDiscovery(hostIsClient: Boolean = this.hostIsClient) { - DiscoveryServer.startListening(port, discoverPing, hostFilter, hostIsClient, scope) - discoveryTimer?.cancel() - - if (discoveryTimeout > 0L) { - discoveryTimer = scope.launch(Dispatchers.IO) { - delay(discoveryTimeout) - discoveryTimeoutListener?.invoke() - stopDiscovery() - } - } - } - - fun stopDiscovery() { - DiscoveryServer.stopListening() - discoveryTimer?.cancel() - } - - class Builder(private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) { - private var discoveryTimeout by Delegates.notNull() - private var discoveryTimeoutListener: (suspend () -> Unit)? = null - private var discoverableTimeout by Delegates.notNull() - private var discoverableTimeoutListener: (suspend () -> Unit)? = null - private var discoverPing: Long = 1000L - private var port by Delegates.notNull() - private var hostFilter: Regex = Regex("^$") - private var hostIsClientToo = false - - fun setDiscoveryTimeout(timeoutMilli: Long) = apply { - this.discoveryTimeout = timeoutMilli - } - - fun setDiscoveryTimeout(duration: Duration) = apply { - this.discoveryTimeout = duration.inWholeMilliseconds - } - - fun setDiscoveryTimeoutListener(listener: suspend () -> Unit) = apply { - this.discoveryTimeoutListener = listener - } - - fun setDiscoverableTimeout(timeoutMilli: Long) = apply { - this.discoverableTimeout = timeoutMilli - } - - fun setDiscoverableTimeout(duration: Duration) = apply { - this.discoverableTimeout = duration.inWholeMilliseconds - } - - fun setDiscoverableTimeoutListener(listener: suspend () -> Unit) = apply { - this.discoverableTimeoutListener = listener - } - - fun setPing(intervalMilli: Long) = apply { - this.discoverPing = intervalMilli - } - - fun setPing(duration: Duration) = apply { - this.discoverPing = duration.inWholeMilliseconds - } - - fun setPort(port: Int) = apply { - this.port = port - } - - fun setHostFilter(filter: Regex) = apply { - this.hostFilter = filter - } - - fun setScope(scope: CoroutineScope) = apply { - this.scope = scope - } - - fun setHostIsClient(`is`: Boolean) = apply { - this.hostIsClientToo = `is` - } - - fun build() = Discovery( - discoveryTimeout, - discoveryTimeoutListener, - discoverableTimeout, - discoverableTimeoutListener, - discoverPing, - port, - hostFilter, - hostIsClientToo, - scope - ) - } -} - -fun CoroutineScope.discovery(builder: Discovery.Builder.() -> Unit) = Discovery.Builder(this).apply(builder).build() \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryClient.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryClient.kt deleted file mode 100644 index 829ea136..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryClient.kt +++ /dev/null @@ -1,63 +0,0 @@ -package dev.datlag.burningseries.k2k.discover - -import dev.datlag.burningseries.k2k.Constants -import dev.datlag.tooling.async.suspendCatching -import dev.datlag.tooling.scopeCatching -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openWriteChannel -import io.ktor.utils.io.close -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import dev.datlag.burningseries.k2k.NetInterface - -internal data object DiscoveryClient { - private var socket = aSocket(SelectorManager(Dispatchers.IO)).udp() - - private var broadcastJob: Job? = null - - internal fun startBroadcasting( - port: Int, - ping: Long, - data: ByteArray, - scope: CoroutineScope - ) { - broadcastJob?.cancel() - broadcastJob = scope.launch(Dispatchers.IO) { - while (currentCoroutineContext().isActive) { - send(port, data) - delay(ping) - } - } - } - - internal fun stopBroadcasting() { - broadcastJob?.cancel() - socket = aSocket(SelectorManager(Dispatchers.IO)).udp() - } - - private suspend fun send(port: Int, data: ByteArray) { - suspend fun writeToSocket(address: String, port: Int) = suspendCatching { - val socketConnection = socket.connect(InetSocketAddress(address, port)) { - broadcast = true - reuseAddress = true - } - - val output = socketConnection.openWriteChannel(autoFlush = true) - output.writeFully(data, 0, data.size) - output.close() - socketConnection.close() - } - - writeToSocket(Constants.BROADCAST_ADDRESS, port) - for (address in NetInterface.getAddresses()) { - writeToSocket(address, port) - } - } -} \ No newline at end of file diff --git a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryServer.kt b/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryServer.kt deleted file mode 100644 index 4cf8df0e..00000000 --- a/k2k/src/commonMain/kotlin/dev/datlag/burningseries/k2k/discover/DiscoveryServer.kt +++ /dev/null @@ -1,98 +0,0 @@ -package dev.datlag.burningseries.k2k.discover - -import dev.datlag.burningseries.k2k.Constants -import dev.datlag.burningseries.k2k.Host -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.aSocket -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.withContext -import dev.datlag.burningseries.k2k.NetInterface -import dev.datlag.tooling.scopeCatching -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.openReadChannel -import io.ktor.utils.io.core.readUTF8Line -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -internal data object DiscoveryServer { - private var socket = aSocket(SelectorManager(Dispatchers.IO)).udp() - - private val currentHostIPs = MutableStateFlow>(persistentSetOf()) - internal val hosts = MutableStateFlow>(persistentSetOf()) - private var listenJob: Job? = null - - internal fun startListening( - port: Int, - ping: Long, - hostFilter: Regex, - hostIsClient: Boolean, - scope: CoroutineScope - ) { - listenJob?.cancel() - listenJob = scope.launch(Dispatchers.IO) { - updateCurrentDeviceIPs() - listen(port, ping, hostFilter, hostIsClient) - } - } - - internal fun stopListening() { - listenJob?.cancel() - socket = aSocket(SelectorManager(Dispatchers.IO)).udp() - currentHostIPs.update { persistentSetOf() } - hosts.update { persistentSetOf() } - } - - private suspend fun updateCurrentDeviceIPs() { - currentHostIPs.update { NetInterface.getAddresses() } - } - - private suspend fun listen(port: Int, ping: Long, filter: Regex, hostIsClient: Boolean) { - val socketAddress = InetSocketAddress(Constants.BROADCAST_SOCKET, port) - val serverSocket = socket.bind(socketAddress) { - broadcast = true - reuseAddress = true - } - - while (true) { - serverSocket.openReadChannel() - serverSocket.incoming.consumeEach { datagram -> - try { - val receivedPacket = datagram.packet.readUTF8Line() - if (!receivedPacket.isNullOrBlank()) { - val host = Constants.json.decodeFromString(receivedPacket).apply { - val inetSocketAddress = datagram.address as InetSocketAddress - this.hostAddress = inetSocketAddress.hostname - this.port = inetSocketAddress.port - } - - val keepHosts = hosts.value.toMutableSet() - - if (hostIsClient || !currentHostIPs.value.contains(host.hostAddress)) { - if (host.filterMatch.matches(filter)) { - keepHosts.add(host) - } - } - - hosts.update { keepHosts.toImmutableSet() } - } - } catch (e: Throwable) { - serverSocket.close() - if (e is CancellationException) { - throw e - } - } - } - - delay(ping) - } - } -} \ No newline at end of file diff --git a/k2k/src/jvmMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.jvm.kt b/k2k/src/jvmMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.jvm.kt deleted file mode 100644 index 3cafa358..00000000 --- a/k2k/src/jvmMain/kotlin/dev/datlag/burningseries/k2k/NetInterface.jvm.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.datlag.burningseries.k2k - -import dev.datlag.tooling.scopeCatching -import kotlinx.collections.immutable.ImmutableSet -import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.CancellationException -import java.net.InetAddress -import java.net.NetworkInterface - -actual object NetInterface { - actual fun getAddresses(): ImmutableSet { - val interfaces = NetworkInterface.getNetworkInterfaces() - val updatedIPs = mutableSetOf() - - while (interfaces.hasMoreElements()) { - val networkInterface = interfaces.nextElement() - try { - if (networkInterface.isLoopback || !networkInterface.isUp) continue - - networkInterface.interfaceAddresses.forEach { - if (it.broadcast != null) { - updatedIPs.add(it.broadcast.hostAddress) - } - } - } catch (e: Throwable) { - if (e is CancellationException) { - throw e - } - } - } - - return updatedIPs.toImmutableSet() - } - - actual fun getLocalAddress(): String { - return InetAddress.getLocalHost().hostAddress - } -} \ No newline at end of file diff --git a/network/src/commonMain/kotlin/dev/datlag/burningseries/network/BurningSeries.kt b/network/src/commonMain/kotlin/dev/datlag/burningseries/network/BurningSeries.kt index dec68a0d..1ce561af 100644 --- a/network/src/commonMain/kotlin/dev/datlag/burningseries/network/BurningSeries.kt +++ b/network/src/commonMain/kotlin/dev/datlag/burningseries/network/BurningSeries.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -internal data object BurningSeries { +data object BurningSeries { private suspend fun document(client: HttpClient, url: String): Document? = suspendCatching { return@suspendCatching suspendCatching { @@ -140,7 +140,7 @@ internal data object BurningSeries { }?.flatten()?.toImmutableSet() ?: persistentSetOf() } - internal suspend fun series(client: HttpClient, href: String): Series? { + suspend fun series(client: HttpClient, href: String): Series? { val doc = document(client, BSUtil.fixSeriesHref(href)) ?: return null val titleElement = doc.firstClass("serie")?.firstTag("h2") ?: return null diff --git a/network/src/commonMain/kotlin/dev/datlag/burningseries/network/state/EpisodeState.kt b/network/src/commonMain/kotlin/dev/datlag/burningseries/network/state/EpisodeState.kt index d6c7e878..207eec22 100644 --- a/network/src/commonMain/kotlin/dev/datlag/burningseries/network/state/EpisodeState.kt +++ b/network/src/commonMain/kotlin/dev/datlag/burningseries/network/state/EpisodeState.kt @@ -5,6 +5,9 @@ import dev.datlag.skeo.DirectLink import kotlinx.collections.immutable.ImmutableCollection sealed interface EpisodeState { + val isTvLoading: Boolean + get() = this is Loading || this is SuccessHoster || this is SuccessStream + data object None : EpisodeState sealed interface EpisodeHolder : EpisodeState { diff --git a/settings.gradle.kts b/settings.gradle.kts index a153cd08..98672506 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,7 +6,6 @@ include(":network") include(":firebase") include(":database") include(":github") -include(":k2k") pluginManagement { repositories {