diff --git a/.gitignore b/.gitignore index 55cb98d..269396b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # Android and Gradle generated state. /android/.gradle/ +/android/.kotlin/ /android/build/ /android/*/build/ /android/local.properties diff --git a/Cargo.lock b/Cargo.lock index 9459161..b1c1399 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "pstr-android" -version = "0.1.1" +version = "0.2.0" dependencies = [ "android_logger", "jni 0.21.1", @@ -4281,7 +4281,7 @@ dependencies = [ [[package]] name = "pstr-app" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "eframe", @@ -4303,7 +4303,7 @@ dependencies = [ [[package]] name = "pstr-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "pstr-core" -version = "0.1.1" +version = "0.2.0" dependencies = [ "directories", "futures", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "pstr-meta" -version = "0.1.1" +version = "0.2.0" dependencies = [ "pstr-core", "reqwest", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "pstr-player" -version = "0.1.1" +version = "0.2.0" dependencies = [ "async-trait", "libloading", @@ -4366,7 +4366,7 @@ dependencies = [ [[package]] name = "pstr-stream" -version = "0.1.1" +version = "0.2.0" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 6387853..7e49d9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-stream" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 362c877..890af25 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -215,6 +215,7 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.navigation.suite) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt b/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt index c4ed3e6..ae383a6 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/LibmpvPlayerSurface.kt @@ -14,12 +14,27 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.Audiotrack import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -36,6 +51,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import io.narl.protonstream.native.NativeRuntime import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uniffi.pstr_android.AndroidEngine @@ -113,7 +129,13 @@ private data class PlaybackStartup( ) @Composable -fun LibmpvPlayerSurface(titleKey: String, episode: EpisodeRecord, host: LibmpvHost? = null) { +fun LibmpvPlayerSurface( + titleKey: String, + episode: EpisodeRecord, + host: LibmpvHost? = null, + isFullscreen: Boolean = false, + onToggleFullscreen: () -> Unit = {} +) { if (host == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Playback is unavailable: this build does not include libmpv.", color = Color.White) @@ -156,7 +178,20 @@ fun LibmpvPlayerSurface(titleKey: String, episode: EpisodeRecord, host: LibmpvHo scope.launch { session.closeEngine() } } } - Box(Modifier.fillMaxSize()) { + + var showControls by remember { mutableStateOf(true) } + LaunchedEffect(showControls) { + if (showControls) { + delay(4000) + showControls = false + } + } + + Box(Modifier.fillMaxSize().clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null, + onClick = { showControls = !showControls } + )) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> @@ -169,7 +204,20 @@ fun LibmpvPlayerSurface(titleKey: String, episode: EpisodeRecord, host: LibmpvHo } }, ) - if (host is NativeMpvHost) PlayerControls(titleKey, episode, host, Modifier.align(Alignment.BottomCenter)) + AnimatedVisibility( + visible = showControls, + modifier = Modifier.align(Alignment.BottomCenter), + enter = fadeIn(), + exit = fadeOut() + ) { + if (host is NativeMpvHost) PlayerControls( + titleKey, + episode, + host, + isFullscreen, + onToggleFullscreen + ) + } playbackError?.let { message -> Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp)) } @@ -179,7 +227,14 @@ fun LibmpvPlayerSurface(titleKey: String, episode: EpisodeRecord, host: LibmpvHo private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}" @Composable -private fun PlayerControls(titleKey: String, episode: EpisodeRecord, host: NativeMpvHost, modifier: Modifier = Modifier) { +private fun PlayerControls( + titleKey: String, + episode: EpisodeRecord, + host: NativeMpvHost, + isFullscreen: Boolean, + onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier +) { val state by host.state.collectAsState() val tracks by host.tracks.collectAsState() val scope = rememberCoroutineScope() @@ -199,17 +254,32 @@ private fun PlayerControls(titleKey: String, episode: EpisodeRecord, host: Nativ valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f), ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Button(onClick = { host.setPaused(!state.paused) }) { Text(if (state.paused) "Play" else "Pause") } + IconButton(onClick = { host.setPaused(!state.paused) }) { + Icon(if (state.paused) Icons.Default.PlayArrow else Icons.Default.Pause, contentDescription = "Play/Pause", tint = Color.White) + } Text("${formatTime(state.position)} / ${formatTime(state.duration)}", color = Color.White) - Button(onClick = { host.setMuted(!state.muted) }) { Text(if (state.muted) "Unmute" else "Mute") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = { host.setMuted(!state.muted) }) { + Icon(if (state.muted) Icons.Default.VolumeOff else Icons.Default.VolumeUp, contentDescription = "Mute", tint = Color.White) + } + IconButton(onClick = onToggleFullscreen) { + Icon(if (isFullscreen) Icons.Default.FullscreenExit else Icons.Default.Fullscreen, contentDescription = "Fullscreen", tint = Color.White) + } + } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { choosing = "audio" }) { - Text("Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"}", maxLines = 1) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { choosing = "audio" }) { + Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White) } - Button(onClick = { choosing = "sub" }) { - Text("Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", maxLines = 1) + IconButton(onClick = { choosing = "sub" }) { + Icon(Icons.Default.Subtitles, contentDescription = "Subtitles", tint = Color.White) } + Text( + "Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"} • " + + "Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", + color = Color.White, + style = MaterialTheme.typography.bodySmall + ) } } choosing?.let { type -> diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 7dea202..9d7fa2a 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -1,5 +1,7 @@ package io.narl.protonstream.ui +import android.content.res.Configuration +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -41,8 +43,20 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,6 +68,8 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -82,11 +98,11 @@ import uniffi.pstr_android.MetadataProvider import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.LibmpvPlayerSurface -private enum class Destination(val label: String, val glyph: String) { - Library("Library", "▦"), - Shares("Shares", "⇄"), - Downloads("Downloads", "⇩"), - Settings("Settings", "⚙"), +private enum class Destination(val label: String, val icon: ImageVector) { + Library("Library", Icons.Default.VideoLibrary), + Shares("Shares", Icons.Default.Share), + Downloads("Downloads", Icons.Default.Download), + Settings("Settings", Icons.Default.Settings), } @OptIn(ExperimentalMaterial3Api::class) @@ -123,7 +139,7 @@ fun ProtonStreamApp( item( selected = destination == item, onClick = { destination = item; selectedTitle = null }, - icon = { Text(item.glyph) }, + icon = { Icon(item.icon, contentDescription = item.label) }, label = { Text(item.label) }, ) } @@ -136,10 +152,14 @@ fun ProtonStreamApp( title = { Text(selectedTitle?.name ?: "proton-stream") }, actions = { if (destination == Destination.Library) { - FilledTonalButton(onClick = model::refresh, enabled = !state.refreshing) { - Text(if (state.refreshing) "Refreshing…" else "Refresh") + if (state.refreshing) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + } else { + IconButton(onClick = model::refresh) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } } - Spacer(Modifier.width(12.dp)) } }, ) @@ -190,6 +210,7 @@ private fun LibraryScreen( modifier = Modifier.fillMaxWidth(), singleLine = true, placeholder = { Text("Search library") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) } ) Spacer(Modifier.height(16.dp)) if (state.loading) { @@ -252,9 +273,73 @@ private fun TitleScreen( }.onFailure(onPreferenceError) } if (playing != null) { - Box(Modifier.fillMaxSize().padding(padding).background(Color.Black)) { - LibmpvPlayerSurface(title.key, playing!!, playerHost) - FilledTonalButton(onClick = { playerHost?.stop(); playing = null }, Modifier.padding(16.dp)) { Text("Back") } + androidx.compose.ui.window.Dialog( + onDismissRequest = { + playerHost?.stop() + playing = null + }, + properties = androidx.compose.ui.window.DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) + ) { + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + var isFullscreen by remember { mutableStateOf(isLandscape) } + val activity = context as? android.app.Activity + + LaunchedEffect(isLandscape, isFullscreen) { + val window = activity?.window ?: return@LaunchedEffect + val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) + if (isLandscape || isFullscreen) { + controller.hide(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + if (isFullscreen && !isLandscape) { + activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } else { + controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + + DisposableEffect(Unit) { + onDispose { + val window = activity?.window + if (window != null) { + val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) + controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + } + activity?.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + + BackHandler { + if (isFullscreen && !isLandscape) { + isFullscreen = false + } else { + playerHost?.stop() + playing = null + } + } + + Box(Modifier.fillMaxSize().background(Color.Black)) { + LibmpvPlayerSurface( + title.key, + playing!!, + playerHost, + isFullscreen = isLandscape || isFullscreen, + onToggleFullscreen = { isFullscreen = !isFullscreen } + ) + if (!isLandscape && !isFullscreen) { + IconButton( + onClick = { playerHost?.stop(); playing = null }, + Modifier.padding(padding).padding(16.dp).background(Color.Black.copy(alpha = 0.5f), androidx.compose.foundation.shape.CircleShape) + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close player", tint = Color.White) + } + } + } } return } @@ -264,7 +349,11 @@ private fun TitleScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - FilledTonalButton(onClick = onBack) { Text("Back to library") } + TextButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Back to library") + } RemoteArtwork( title.backdropUrl ?: title.posterUrl, title.canonicalName ?: title.name, @@ -286,9 +375,15 @@ private fun TitleScreen( title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) Text("Download show") } - FilledTonalButton(onClick = { showMatch = true }) { Text("Change match") } + FilledTonalButton(onClick = { showMatch = true }) { + Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Change match") + } } Text("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp)) Text("Preferred tracks", style = MaterialTheme.typography.titleMedium) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index aefa961..d7b8f36 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -28,6 +28,7 @@ androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-compose-material3-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" } junit = { module = "junit:junit", version.ref = "junit" }