added icons instead of text

This commit is contained in:
nvrl committed 2026-08-05 13:33:53 +02:00
1 parent f4961fd6a8
commit b399f84f54
7 files changed
+201 -33

No files matched your search

+1
View File
@@ -10,6 +10,7 @@
# Android and Gradle generated state. # Android and Gradle generated state.
/android/.gradle/ /android/.gradle/
/android/.kotlin/
/android/build/ /android/build/
/android/*/build/ /android/*/build/
/android/local.properties /android/local.properties
Generated
+7 -7
View File
@@ -4262,7 +4262,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-android" name = "pstr-android"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni 0.21.1", "jni 0.21.1",
@@ -4281,7 +4281,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-app" name = "pstr-app"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"eframe", "eframe",
@@ -4303,7 +4303,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-cli" name = "pstr-cli"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-core" name = "pstr-core"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures", "futures",
@@ -4339,7 +4339,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-meta" name = "pstr-meta"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"pstr-core", "pstr-core",
"reqwest", "reqwest",
@@ -4352,7 +4352,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-player" name = "pstr-player"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"libloading", "libloading",
@@ -4366,7 +4366,7 @@ dependencies = [
[[package]] [[package]]
name = "pstr-stream" name = "pstr-stream"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"futures", "futures",
+1 -1
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.2.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://github.com/narrrl/proton-stream" repository = "https://github.com/narrrl/proton-stream"
+1
View File
@@ -215,6 +215,7 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.navigation.suite) implementation(libs.androidx.compose.material3.navigation.suite)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
@@ -14,12 +14,27 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -36,6 +51,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uniffi.pstr_android.AndroidEngine import uniffi.pstr_android.AndroidEngine
@@ -113,7 +129,13 @@ private data class PlaybackStartup(
) )
@Composable @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) { if (host == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Playback is unavailable: this build does not include libmpv.", color = Color.White) 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() } 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( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { context -> 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 -> playbackError?.let { message ->
Text(message, color = Color.White, modifier = Modifier.align(Alignment.Center).background(Color.Black.copy(alpha = 0.8f)).padding(16.dp)) 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}" private fun sessionKey(episode: EpisodeRecord) = "${episode.shareId}:${episode.linkId}"
@Composable @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 state by host.state.collectAsState()
val tracks by host.tracks.collectAsState() val tracks by host.tracks.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -199,17 +254,32 @@ private fun PlayerControls(titleKey: String, episode: EpisodeRecord, host: Nativ
valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f), valueRange = 0f..state.duration.toFloat().coerceAtLeast(1f),
) )
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { 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) 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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { choosing = "audio" }) { IconButton(onClick = { choosing = "audio" }) {
Text("Audio: ${tracks.firstOrNull { it.type == "audio" && it.selected }?.label ?: "default"}", maxLines = 1) Icon(Icons.Default.Audiotrack, contentDescription = "Audio", tint = Color.White)
} }
Button(onClick = { choosing = "sub" }) { IconButton(onClick = { choosing = "sub" }) {
Text("Subtitles: ${tracks.firstOrNull { it.type == "sub" && it.selected }?.label ?: "off"}", maxLines = 1) 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 -> choosing?.let { type ->
@@ -1,5 +1,7 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -41,8 +43,20 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TopAppBar 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.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -54,6 +68,8 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation 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.NativeMpvHost
import io.narl.protonstream.playback.LibmpvPlayerSurface import io.narl.protonstream.playback.LibmpvPlayerSurface
private enum class Destination(val label: String, val glyph: String) { private enum class Destination(val label: String, val icon: ImageVector) {
Library("Library", "▦"), Library("Library", Icons.Default.VideoLibrary),
Shares("Shares", "⇄"), Shares("Shares", Icons.Default.Share),
Downloads("Downloads", "⇩"), Downloads("Downloads", Icons.Default.Download),
Settings("Settings", "⚙"), Settings("Settings", Icons.Default.Settings),
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -123,7 +139,7 @@ fun ProtonStreamApp(
item( item(
selected = destination == item, selected = destination == item,
onClick = { destination = item; selectedTitle = null }, onClick = { destination = item; selectedTitle = null },
icon = { Text(item.glyph) }, icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }, label = { Text(item.label) },
) )
} }
@@ -136,10 +152,14 @@ fun ProtonStreamApp(
title = { Text(selectedTitle?.name ?: "proton-stream") }, title = { Text(selectedTitle?.name ?: "proton-stream") },
actions = { actions = {
if (destination == Destination.Library) { if (destination == Destination.Library) {
FilledTonalButton(onClick = model::refresh, enabled = !state.refreshing) { if (state.refreshing) {
Text(if (state.refreshing) "Refreshing…" else "Refresh") 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(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("Search library") }, placeholder = { Text("Search library") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (state.loading) { if (state.loading) {
@@ -252,9 +273,73 @@ private fun TitleScreen(
}.onFailure(onPreferenceError) }.onFailure(onPreferenceError)
} }
if (playing != null) { if (playing != null) {
Box(Modifier.fillMaxSize().padding(padding).background(Color.Black)) { androidx.compose.ui.window.Dialog(
LibmpvPlayerSurface(title.key, playing!!, playerHost) onDismissRequest = {
FilledTonalButton(onClick = { playerHost?.stop(); playing = null }, Modifier.padding(16.dp)) { Text("Back") } 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 return
} }
@@ -264,7 +349,11 @@ private fun TitleScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { 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( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.canonicalName ?: title.name,
@@ -286,9 +375,15 @@ private fun TitleScreen(
title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) } title.overview?.let { Text(it, modifier = Modifier.padding(top = 10.dp)) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
Button(onClick = { DownloadCoordinator.enqueue(context, title.seasons.flatMap { it.episodes }) }) { 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") 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("${title.watchedCount} of ${title.episodeCount} watched", Modifier.padding(vertical = 12.dp))
Text("Preferred tracks", style = MaterialTheme.typography.titleMedium) Text("Preferred tracks", style = MaterialTheme.typography.titleMedium)
+1
View File
@@ -28,6 +28,7 @@ androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime
androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } 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-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" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" } rustls-platform-verifier = { module = "rustls:rustls-platform-verifier", version = "0.1.1" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }