improved android ui

This commit is contained in:
nvrl committed 2026-08-09 19:12:41 +02:00
1 parent f1b3ad9637
commit 46bb68e3a5
8 files changed
+402 -67

No files matched your search

+7 -1
View File
@@ -12,6 +12,12 @@ add_library(pstr_mpv SHARED pstr_mpv.cpp)
add_library(mpv SHARED IMPORTED) add_library(mpv SHARED IMPORTED)
set_target_properties(mpv PROPERTIES set_target_properties(mpv PROPERTIES
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so") IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libmpv.so")
# libmpv links FFmpeg dynamically and does not re-export it, so registering the
# JavaVM that MediaCodec needs means linking libavcodec ourselves. It is already
# packaged in the same ABI directory as part of libmpv's dependency closure.
add_library(avcodec SHARED IMPORTED)
set_target_properties(avcodec PROPERTIES
IMPORTED_LOCATION "${PSTR_MPV_ROOT}/jniLibs/${ANDROID_ABI}/libavcodec.so")
target_compile_features(pstr_mpv PRIVATE cxx_std_20) target_compile_features(pstr_mpv PRIVATE cxx_std_20)
target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror) target_compile_options(pstr_mpv PRIVATE -Wall -Wextra -Werror)
@@ -20,4 +26,4 @@ target_include_directories(pstr_mpv PRIVATE "${PSTR_MPV_ROOT}/include")
# packaged into the same ABI directory in the APK. # packaged into the same ABI directory in the APK.
target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}") target_link_directories(pstr_mpv PRIVATE "${PSTR_RUST_JNI_ROOT}/${ANDROID_ABI}")
target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so") target_link_options(pstr_mpv PRIVATE "-Wl,-l:libpstr_android.so")
target_link_libraries(pstr_mpv PRIVATE mpv android EGL log) target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log)
+37 -1
View File
@@ -9,6 +9,10 @@
#include <mpv/render_gl.h> #include <mpv/render_gl.h>
#include <mpv/stream_cb.h> #include <mpv/stream_cb.h>
extern "C" {
#include <libavcodec/jni.h>
}
#include <atomic> #include <atomic>
#include <algorithm> #include <algorithm>
#include <charconv> #include <charconv>
@@ -212,7 +216,15 @@ class Player {
option("vo", "libmpv"); option("vo", "libmpv");
option("force-window", "no"); option("force-window", "no");
option("video-timing-offset", "0"); option("video-timing-offset", "0");
option("hwdec", "auto-safe"); // `-copy-safe`, not `-safe`. The direct MediaCodec decoders render into
// an `ANativeWindow` the decoder is handed at init, and this player has
// none to give: the picture goes through `vo_libmpv` and an EGL pbuffer,
// and the SurfaceView — when there is one at all — attaches later than
// the load. `auto-safe` picks them anyway and every file opens with
// `hevc_mediacodec: Both surface and native_window are NULL` before
// falling back. The copy variants decode in hardware and hand back
// ordinary frames, which is what this render path can actually use.
option("hwdec", "auto-copy-safe");
option("cache", "yes"); option("cache", "yes");
option("cache-secs", "30"); option("cache-secs", "30");
option("demuxer-readahead-secs", "30"); option("demuxer-readahead-secs", "30");
@@ -546,8 +558,16 @@ class Player {
/// A failure cascades — one unreadable header produces a dozen follow-on /// A failure cascades — one unreadable header produces a dozen follow-on
/// complaints — and the first line is the one that names the cause. Cleared /// complaints — and the first line is the one that names the cause. Cleared
/// by [take_error], which is how the UI consumes it. /// by [take_error], which is how the UI consumes it.
///
/// Only mpv's own errors. Everything libavcodec and libavformat complain
/// about arrives under an `ffmpeg/…` prefix, and mpv is the thing that
/// decides whether any of it mattered: a decoder that refuses to
/// initialise is an error there and a fallback here, so surfacing those
/// puts a dialog over a file that goes on to play perfectly. When one of
/// them really is fatal mpv says so itself, under a prefix of its own.
void record_log(const mpv_event_log_message &message) { void record_log(const mpv_event_log_message &message) {
if (!message.text) return; if (!message.text) return;
if (message.prefix && !std::strncmp(message.prefix, "ffmpeg", 6)) return;
std::lock_guard lock(state_mutex_); std::lock_guard lock(state_mutex_);
if (!error_.empty()) return; if (!error_.empty()) return;
error_.assign(message.text); error_.assign(message.text);
@@ -768,6 +788,22 @@ void reject_null_surface(JNIEnv *env) noexcept {
} // namespace } // namespace
/// FFmpeg's MediaCodec decoders call into Android's Java API, and they can only
/// reach it through a JavaVM handed to them explicitly — there is no way for
/// them to discover one. Without this, every hardware decoder fails to open
/// with "hevc_mediacodec: No Java virtual machine has been registered" and
/// playback falls back to software decoding, which on a phone means a hot
/// device and dropped frames on anything above 1080p.
///
/// mpv-android's own JNI layer does this in its `create` entry point; this
/// library replaces that layer, so the registration has to happen here. The
/// loader calls this once per process before any native method, which is
/// earlier than any decoder can run.
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
av_jni_set_java_vm(vm, nullptr);
return JNI_VERSION_1_6;
}
extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept { extern "C" JNIEXPORT jlong JNICALL Java_io_narl_protonstream_playback_NativeMpvHost_nativeCreate(JNIEnv *, jclass) noexcept {
return c_boundary<jlong>(0, [] { return c_boundary<jlong>(0, [] {
auto player = std::make_unique<Player>(); auto player = std::make_unique<Player>();
@@ -9,6 +9,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -20,10 +21,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
@@ -84,6 +88,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
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
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -127,6 +132,9 @@ import io.narl.protonstream.playback.PlayerScreen
*/ */
private const val CONTINUE_WATCHING_MAX = 12 private const val CONTINUE_WATCHING_MAX = 12
/** How much room the floating mini transport needs at the foot of a page. */
private val MINI_TRANSPORT_INSET = 88.dp
/** What the player was asked to open: a title's episodes, and which one. */ /** What the player was asked to open: a title's episodes, and which one. */
private data class PlayRequest( private data class PlayRequest(
val title: TitleRecord, val title: TitleRecord,
@@ -222,6 +230,16 @@ fun ProtonStreamApp(
destination = Destination.Library destination = Destination.Library
} }
val minimizedHost = (playerHost as? NativeMpvHost)?.takeIf { playing != null && playerMinimized } val minimizedHost = (playerHost as? NativeMpvHost)?.takeIf { playing != null && playerMinimized }
// The mini transport floats over the page, so the page has to end
// above it. Without this the last episode of a season sits under the
// bar and cannot be scrolled clear of it.
val direction = LocalLayoutDirection.current
val body = if (minimizedHost == null) padding else PaddingValues(
start = padding.calculateStartPadding(direction),
end = padding.calculateEndPadding(direction),
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding() + MINI_TRANSPORT_INSET,
)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AnimatedContent(destination, label = "primary navigation") { target -> AnimatedContent(destination, label = "primary navigation") { target ->
when (target) { when (target) {
@@ -235,7 +253,7 @@ fun ProtonStreamApp(
playerMinimized = false playerMinimized = false
}, },
{ selectedTitleKey = it.key }, { selectedTitleKey = it.key },
padding, body,
) )
} else { } else {
TitleScreen( TitleScreen(
@@ -250,7 +268,7 @@ fun ProtonStreamApp(
model::reportError, model::reportError,
model::reloadAfterMetadataChange, model::reloadAfterMetadataChange,
model::setWatched, model::setWatched,
padding, body,
) )
} }
Destination.Shares -> SharesScreen( Destination.Shares -> SharesScreen(
@@ -259,7 +277,7 @@ fun ProtonStreamApp(
model::repairShare, model::repairShare,
model::refreshShare, model::refreshShare,
model::removeShare, model::removeShare,
padding, body,
) )
Destination.Downloads -> DownloadsScreen( Destination.Downloads -> DownloadsScreen(
state, state,
@@ -267,7 +285,7 @@ fun ProtonStreamApp(
model::pauseDownload, model::pauseDownload,
model::resumeDownload, model::resumeDownload,
model::deletePartial, model::deletePartial,
padding, body,
) )
Destination.Settings -> SettingsScreen( Destination.Settings -> SettingsScreen(
state, state,
@@ -275,7 +293,7 @@ fun ProtonStreamApp(
{ model.matchTitles(force = true) }, { model.matchTitles(force = true) },
model::clearBlockCache, model::clearBlockCache,
model::removeAllOffline, model::removeAllOffline,
padding, body,
) )
} }
} }
@@ -557,7 +575,14 @@ private fun TitleScreen(
modifier = Modifier.padding(top = 6.dp), modifier = Modifier.padding(top = 6.dp),
) )
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)) { // Wrapping, not squeezing: a fixed row hands the last button the
// width the others left over, which is how "More on AniList" ended
// up set one letter per line on a phone.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
// What a press on the poster should play: whatever was left // What a press on the poster should play: whatever was left
// part-watched, else the first unwatched episode, else the // part-watched, else the first unwatched episode, else the
// first — the desktop client's `next_up`. // first — the desktop client's `next_up`.
@@ -579,8 +604,10 @@ private fun TitleScreen(
enabled = playerReady && playlist.isNotEmpty(), enabled = playerReady && playlist.isNotEmpty(),
) { Text("Start over") } ) { Text("Start over") }
} }
} // The rest in the same flow, and all the same kind of button:
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { // one filled primary for the thing the page is for, tonal for
// everything else. A text link among filled buttons was what
// made this block read as three unrelated rows of controls.
FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) { FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, playlist) }) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -594,7 +621,7 @@ private fun TitleScreen(
// The provider's own page for this title: where a viewer goes to // The provider's own page for this title: where a viewer goes to
// check that the thing the app matched is the thing they have. // check that the thing the app matched is the thing they have.
title.externalUrl?.let { url -> title.externalUrl?.let { url ->
TextButton(onClick = { OutlinedButton(onClick = {
runCatching { runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError) }.onFailure(onPreferenceError)
@@ -648,37 +675,57 @@ private fun TitleScreen(
} }
title.seasons.forEach { season -> title.seasons.forEach { season ->
val isExpanded = expandedSeasons.contains(season.label) val isExpanded = expandedSeasons.contains(season.label)
item { item(key = "season/${season.label}") {
// A header, not a row with a button parked on the end of it: the
// label and its count are the only things that grow, and the one
// action is an icon of fixed width, so every season header down
// the page starts and ends in the same place.
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 12.dp) .padding(top = 12.dp)
.clip(MaterialTheme.shapes.medium)
.clickable { .clickable {
expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label
} }
.padding(vertical = 8.dp), .padding(vertical = 8.dp, horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Icon(
Icon( if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = if (isExpanded) "Collapse" else "Expand",
contentDescription = if (isExpanded) "Collapse" else "Expand", )
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
season.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val watched = season.episodes.count(EpisodeRecord::watched)
Text(
"${season.episodes.size} " +
(if (season.episodes.size == 1) "episode" else "episodes") +
if (watched > 0) " · $watched watched" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.width(8.dp))
Text(season.label, style = MaterialTheme.typography.titleLarge)
} }
FilledTonalButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(
Spacer(Modifier.width(8.dp)) Icons.Default.Download,
Text("Download season") contentDescription = "Download ${season.label}",
)
} }
} }
} }
if (isExpanded) { if (isExpanded) {
items(season.episodes, key = { it.linkId }) { episode -> itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode ->
EpisodeRow( EpisodeRow(
episode = episode, episode = episode,
numbering = episode.numbering(season.number, position),
download = downloads.firstOrNull { download = downloads.firstOrNull {
it.shareId == episode.shareId && it.linkId == episode.linkId it.shareId == episode.shareId && it.linkId == episode.linkId
}, },
@@ -703,16 +750,40 @@ private fun TitleScreen(
} }
} }
/**
* `S03E38`, `E38`, or the row's place in the season.
*
* Mirrors `Episode::numbering` in `pstr-core`, plus the desktop client's
* fallback to the season folder's number. The last case is deliberately *not*
* printed as an episode number — nothing in the name numbered this file, and
* `#4` says "fourth in the list" where `E04` would be a claim about the show.
* Some ordinal is required regardless: a season of rows that all read the same
* is a season the viewer cannot navigate at all.
*/
private fun EpisodeRecord.numbering(fallbackSeason: UInt?, position: Int): String {
val season = this.season ?: fallbackSeason
val number = this.number
return when {
season != null && number != null -> "S%02dE%02d".format(season.toInt(), number.toInt())
number != null -> "E%02d".format(number.toInt())
else -> "#${position + 1}"
}
}
/** /**
* One episode: play it, see where it was left, and drive its download. * One episode: play it, see where it was left, and drive its download.
* *
* A row carries the things a viewer acts on — resume, mark seen, download, * The row *is* the play button — the still, the numbering and the name are one
* pause — so they are controls, not a menu: the download button becomes the * target, which is what lets the trailing controls be two icons in a fixed
* progress readout while it runs, which is the same space either way. * column rather than a second row of buttons under every episode. Both were
* needed while the row carried a full-width `Play`: it pushed the download and
* watched controls into whatever width was left, and forty of those down a
* season read as forty differently-arranged rows.
*/ */
@Composable @Composable
private fun EpisodeRow( private fun EpisodeRow(
episode: EpisodeRecord, episode: EpisodeRecord,
numbering: String,
download: RetainedDownload?, download: RetainedDownload?,
playerReady: Boolean, playerReady: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
@@ -723,43 +794,77 @@ private fun EpisodeRow(
) { ) {
val running = download?.status == RetainedDownload.STATUS_RUNNING || val running = download?.status == RetainedDownload.STATUS_RUNNING ||
download?.status == RetainedDownload.STATUS_QUEUED download?.status == RetainedDownload.STATUS_QUEUED
Card(Modifier.fillMaxWidth()) { // The provider's name for the episode when there is one: "The Cave of
Column(Modifier.padding(12.dp)) { // Skulls" says more than the filename it was parsed out of. The filename
// stays underneath it, and is the whole answer when there is no provider.
val name = episode.providerName ?: episode.detail
val detail = episode.detail.takeIf { it != name }
Card(
Modifier
.fillMaxWidth()
.clickable(enabled = playerReady, onClick = onPlay),
) {
Column(Modifier.padding(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// The still, and the largest target on the row for the thing the // With metadata off the provider has no still, and Proton's own
// row is for. With metadata off the provider has no still, and // thumbnail is a frame of this very episode.
// Proton's own thumbnail is a frame of this very episode. Box(
RemoteArtwork(
episode.stillUrl,
episode.providerName ?: episode.label,
Modifier Modifier
.width(96.dp) .width(96.dp)
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(MaterialTheme.shapes.small) .clip(MaterialTheme.shapes.small),
.clickable(enabled = playerReady, onClick = onPlay), contentAlignment = Alignment.Center,
fallback = episode.thumbnailSource, ) {
) RemoteArtwork(
Spacer(Modifier.width(12.dp)) episode.stillUrl,
Button(onClick = onPlay, enabled = playerReady) { name,
Text( Modifier.fillMaxSize(),
when { fallback = episode.thumbnailSource,
!playerReady -> "Player loading…" )
episode.watched -> "Replay" // Over the still rather than beside it: the row already
episode.resumeAt != null -> "Resume" // plays on tap, so this says so without spending a column.
else -> "Play" Icon(
}, Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.padding(4.dp),
) )
} }
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(episode.label, fontWeight = FontWeight.SemiBold)
// The provider's name for the episode when there is one:
// "The Cave of Skulls" says more than the filename it was
// parsed out of. The filename detail stays as the fallback.
Text( Text(
episode.providerName ?: episode.detail, numbering,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
) )
Text(
name,
fontWeight = FontWeight.SemiBold,
// Seen episodes stay legible but stop competing with the
// one the viewer has not watched yet.
color = if (episode.watched) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
detail?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
// Watched is a judgement the viewer is allowed to overrule: a // Watched is a judgement the viewer is allowed to overrule: a
// half-watched episode they are done with, or one the 90 % rule // half-watched episode they are done with, or one the 90 % rule
@@ -775,12 +880,25 @@ private fun EpisodeRow(
}, },
) )
} }
when { // One slot, always the same width, whatever state the download
episode.offline -> Text("Offline", style = MaterialTheme.typography.labelMedium) // is in — including "there is nothing to say", where an empty
running -> FilledTonalButton(onClick = { onPause(download!!) }) { Text("Pause") } // box keeps the rows above and below it aligned.
download != null -> FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") } Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
else -> FilledTonalButton(onClick = onDownload) { when {
Icon(Icons.Default.Download, contentDescription = "Download", modifier = Modifier.size(18.dp)) episode.offline -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Saved offline",
tint = MaterialTheme.colorScheme.tertiary,
)
running -> IconButton(onClick = { onPause(download!!) }) {
Icon(Icons.Default.Pause, contentDescription = "Pause download")
}
download != null -> IconButton(onClick = { onResume(download) }) {
Icon(Icons.Default.Download, contentDescription = "Resume download")
}
else -> IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, contentDescription = "Download")
}
} }
} }
} }
@@ -801,6 +919,7 @@ private fun EpisodeRow(
"${active.status.replaceFirstChar { it.uppercase() }} · " + "${active.status.replaceFirstChar { it.uppercase() }} · " +
"${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -67,7 +67,12 @@ private fun schemeOf(palette: PaletteRecord): ColorScheme {
onPrimaryContainer = color(palette.text), onPrimaryContainer = color(palette.text),
secondary = color(palette.accentAlt), secondary = color(palette.accentAlt),
onSecondary = color(palette.onAccent), onSecondary = color(palette.onAccent),
secondaryContainer = color(palette.cardHover), // The accent taken back towards the page, not the card's hover shade.
// `secondaryContainer` is what fills every tonal button and the
// navigation bar's selected pill, and `cardHover` sits one step off
// `card` — which on a dark flavour made all of them dark grey shapes on
// a dark grey row, readable as text but not as controls.
secondaryContainer = color(palette.accentDim),
onSecondaryContainer = color(palette.text), onSecondaryContainer = color(palette.text),
background = color(palette.background), background = color(palette.background),
onBackground = color(palette.text), onBackground = color(palette.text),
+56 -4
View File
@@ -993,7 +993,7 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
// episode's name rather than the series'. // episode's name rather than the series'.
if row.parsed.episode_title.is_none() if row.parsed.episode_title.is_none()
&& !row.parsed.title.is_empty() && !row.parsed.title.is_empty()
&& row.parsed.title != title && !row.parsed.title.eq_ignore_ascii_case(&title)
{ {
let rest = strip_prefix_title(&row.parsed.title, &title); let rest = strip_prefix_title(&row.parsed.title, &title);
// …unless what is left is a season marker. `Oshi no Ko 3rd // …unless what is left is a season marker. `Oshi no Ko 3rd
@@ -1002,7 +1002,11 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<Catalo
// that is worse than calling none of them anything. // that is worse than calling none of them anything.
if naming::is_season_marker(&rest) { if naming::is_season_marker(&rest) {
row.parsed.season = row.parsed.season.or(naming::parse(&rest).season); row.parsed.season = row.parsed.season.or(naming::parse(&rest).season);
} else { } else if !rest.eq_ignore_ascii_case(&title) {
// Nor when the strip found nothing to remove: a filename
// whose title only *differs* from the folder's — spacing,
// punctuation, a stray article — names the series, not the
// episode, and taking it hands every row the same text.
row.parsed.episode_title = Some(rest); row.parsed.episode_title = Some(rest);
} }
} }
@@ -1086,10 +1090,17 @@ fn ancestry(
/// ///
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called /// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`. /// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
///
/// Case-insensitively, because the two names come from different places: the
/// folder is typed by whoever organised the share and the prefix is whatever a
/// release group capitalised. `[Anime Time] Attack On Titan - 38.mkv` under a
/// folder called `Attack on Titan` differs in exactly one letter, and a
/// case-sensitive strip leaves the whole series name behind as the episode's.
fn strip_prefix_title(episode: &str, series: &str) -> String { fn strip_prefix_title(episode: &str, series: &str) -> String {
let stripped = episode let stripped = episode
.strip_prefix(series) .get(..series.len())
.map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':'])) .filter(|head| head.eq_ignore_ascii_case(series))
.map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':']))
.unwrap_or(episode) .unwrap_or(episode)
.trim(); .trim();
@@ -1642,6 +1653,47 @@ mod tests {
assert_eq!(episode.parsed.episode_title, None); assert_eq!(episode.parsed.episode_title, None);
} }
/// **What every episode of Attack on Titan was called.** The folder and the
/// release group capitalised the same series two ways, and a case-sensitive
/// prefix strip left the whole series name behind — so all fifty-nine rows
/// read `Attack On Titan`, naming nothing and telling the viewer apart from
/// nothing.
#[test]
fn a_series_name_cased_differently_is_not_the_episodes_name() {
let nodes = vec![
root(),
folder("aot", Some("root"), "Attack on Titan"),
video("e38", "aot", "[Anime Time] Attack On Titan - 38.mkv"),
];
let rows = build_rows("share", &nodes);
let episode = row(&rows, "e38");
assert_eq!(episode.parsed.title, "Attack on Titan", "the folder's");
assert_eq!(episode.parsed.episode, Some(38));
assert_eq!(episode.parsed.episode_title, None);
}
/// The prefix strip is case-insensitive for the same reason: what follows
/// the series name is the episode's, however the group capitalised it.
#[test]
fn a_prefix_cased_differently_is_still_stripped() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
video(
"film",
"eva",
"NEON GENESIS EVANGELION - Death & Rebirth.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "film").parsed.episode_title.as_deref(),
Some("Death & Rebirth"),
);
}
/// The deepest naming folder wins, so films inside a collection folder are /// The deepest naming folder wins, so films inside a collection folder are
/// titled individually rather than by the collection. /// titled individually rather than by the collection.
#[test] #[test]
+113
View File
@@ -146,6 +146,119 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places).
## Fixed ## Fixed
### B50 — every episode of a show was named after the show
**Symptom.** All fifty-nine Attack on Titan rows read `Attack On Titan` on both
lines — no numbering, nothing to tell one from the next. Oshi no Ko, in the same
library, was fine.
**Cause.** `build_rows` treats a filename title that differs from the folder's
as the *episode's* own name (`catalog.rs`), which is what turns
`Neon Genesis Evangelion - Death & Rebirth.mkv` under an `NGE` folder into an
episode called `Death & Rebirth`. Both the comparison and the prefix strip were
case-sensitive, and the two names come from different hands: the folder was
`Attack on Titan`, the release group wrote `Attack On Titan`. One letter made
them "different", the strip then found no prefix to remove, and the whole series
name was stored as `episode_title` — which `Episode::label` and
`Episode::detail` both prefer over the numbering.
**Fix.** Both are case-insensitive, and a strip that removes nothing no longer
yields an episode name.
**Verified.** Two table tests in `catalog.rs`; `cargo test --workspace`. Note
that the wrong `episode_title` is *stored*, so an existing catalog needs a
re-crawl (the library's refresh) before rows correct themselves.
### B49 — a recoverable decoder fallback put a dialog over a playing episode
**Symptom.** Every HEVC episode opened with
`hevc_mediacodec: Both surface and native_window are NULL` over the picture, and
then played normally.
**Cause.** Two things, both exposed by B46 finally letting the MediaCodec
decoders initialise. FFmpeg's *direct* MediaCodec decoders render into an
`ANativeWindow` handed to them at init; this player has none to give — the
picture goes through `vo_libmpv` on an EGL pbuffer, and the SurfaceView attaches
later than the load, if at all (background audio). `hwdec=auto-safe` picked them
regardless. Separately, `record_log` promoted *any* error-level mpv log line to
the UI, and libavcodec logs a decoder that refuses to initialise as an error
even where mpv treats it as a fallback.
**Fix.** `hwdec=auto-copy-safe` — the copy variants decode in hardware and hand
back ordinary frames, which is what this render path can use. And `record_log`
ignores the `ffmpeg/…` prefix: mpv reports the failures that are actually fatal
under a prefix of its own.
**Verified.** `gradlew externalNativeBuildDebug`.
### B48 — tonal buttons were grey shapes on a grey page
**Symptom.** Download, Change match, Pause, the navigation bar's selected pill —
every tonal control was a dark grey rounded rectangle a shade off the card under
it. Readable as text, invisible as a control. Episode rows compounded it: forty
rows each arranged themselves around whatever their own download state needed,
so no two lined up.
**Cause.** `Theme.kt` mapped Material's `secondaryContainer` — which fills every
`FilledTonalButton` and the navigation indicator — to the palette's `cardHover`,
one step off `card`. And `EpisodeRow` gave each row a second line of buttons
whose widths depended on the row's own state.
**Fix.** `secondaryContainer` is `accentDim`, the accent taken back towards the
page, which is what the role is for. The episode row is one row: the whole card
plays, the numbering leads the text column, and the two trailing controls sit in
fixed-width slots — including an empty one — so every row aligns. Numbering is
always printed, falling back to the season folder's number and then to the row's
position, so rows the parser could not number still differ. Season headers carry
a count and an icon-only download. The page also ends above the floating mini
transport instead of behind it.
**Verified.** `gradlew compileDebugKotlin`.
### B46 — every hardware decoder failed, and playback ran in software
**Symptom.** Starting an HEVC episode on Android raised
`hevc_mediacodec: No Java virtual machine has been registered` and played
anyway — decoded on the CPU, with the heat and dropped frames that implies on
anything above 1080p.
**Cause.** FFmpeg's MediaCodec decoders reach Android's Java API through a
`JavaVM` handed to them by `av_jni_set_java_vm`; there is no way for them to
find one themselves. mpv-android registers it in its own JNI layer
(`app/src/main/jni/main.cpp`), and `pstr_mpv.cpp` replaces that layer, so
nothing ever called it. `hwdec=auto-safe` then failed to open every MediaCodec
decoder and fell back to software.
**Fix.** `JNI_OnLoad` in `pstr_mpv.cpp` registers the VM — the loader calls it
once per process, before any native method and long before a decoder runs.
libmpv links FFmpeg dynamically without re-exporting it, so `CMakeLists.txt`
links `libavcodec.so` (already packaged as part of libmpv's staged dependency
closure) and `scripts/build-libmpv-android.sh` stages `libavcodec/jni.h`
alongside the mpv headers.
**Verified.** `gradlew externalNativeBuildDebug`; `libpstr_mpv.so` exports
`JNI_OnLoad`, imports `av_jni_set_java_vm@LIBAVCODEC_63` and gains a
`libavcodec.so` `DT_NEEDED`.
### B47 — episode labels were set one character per line
**Symptom.** On a phone, an episode row rendered "S03E01" down the screen a
letter at a time, and the show screen did the same to "More on AniList".
**Cause.** Both were fixed `Row`s in which the flexible child came last: the
episode row gave a 96 dp still, a Play button, a watched toggle and a download
button their intrinsic widths and left `Modifier.weight(1f)` whatever remained,
which on a narrow screen is a few pixels. Compose does not drop a child to make
room; it wraps the text into the column it was given.
**Fix.** The episode row is two rows — still plus label above, controls below —
with the label ellipsized rather than wrapped without bound. The show screen's
two button rows are `FlowRow`s, so a button that does not fit moves to the next
line instead of squeezing its neighbour. The season header gives its label the
weight so a long season name cannot squeeze "Download season".
**Verified.** `gradlew compileDebugKotlin` and `ktlintMainSourceSetCheck`.
### B36 — an invalidated connection stayed live, and releasing a stream could no-op ### B36 — an invalidated connection stayed live, and releasing a stream could no-op
**Symptom.** Removing a share left its authenticated visitor client usable for **Symptom.** Removing a share left its authenticated visitor client usable for
+1 -1
View File
@@ -14,7 +14,7 @@
# builds from the registry alone. # builds from the registry alone.
pkgname=proton-stream pkgname=proton-stream
pkgver=0.2.0 pkgver=0.3.0
pkgrel=1 pkgrel=1
pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share"
arch=('x86_64') arch=('x86_64')
+4
View File
@@ -171,6 +171,10 @@ stage_shared_dependency_closure() {
mkdir -p "$destination/include/mpv" mkdir -p "$destination/include/mpv"
cp "$source_dir/buildscripts/prefix/arm64/include/mpv/"*.h "$destination/include/mpv/" cp "$source_dir/buildscripts/prefix/arm64/include/mpv/"*.h "$destination/include/mpv/"
# FFmpeg's JNI entry points are not part of the mpv API, but registering the
# JavaVM MediaCodec needs is our job now that mpv-android's JNI layer is gone.
mkdir -p "$destination/include/libavcodec"
cp "$source_dir/buildscripts/prefix/arm64/include/libavcodec/jni.h" "$destination/include/libavcodec/"
stage_shared_dependency_closure arm64 arm64-v8a aarch64-linux-android stage_shared_dependency_closure arm64 arm64-v8a aarch64-linux-android
stage_shared_dependency_closure x86_64 x86_64 x86_64-linux-android stage_shared_dependency_closure x86_64 x86_64 x86_64-linux-android