From 46bb68e3a5a7a41d21ae258fa3c69c3d391ae3d3 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 9 Aug 2026 19:12:41 +0200 Subject: [PATCH] improved android ui --- android/app/src/main/cpp/CMakeLists.txt | 8 +- android/app/src/main/cpp/pstr_mpv.cpp | 38 ++- .../narl/protonstream/ui/ProtonStreamApp.kt | 237 +++++++++++++----- .../io/narl/protonstream/ui/theme/Theme.kt | 7 +- crates/pstr-core/src/catalog.rs | 60 ++++- docs/BUGS.md | 113 +++++++++ packaging/PKGBUILD | 2 +- scripts/build-libmpv-android.sh | 4 + 8 files changed, 402 insertions(+), 67 deletions(-) diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index f41c89d..1f1134b 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -12,6 +12,12 @@ add_library(pstr_mpv SHARED pstr_mpv.cpp) add_library(mpv SHARED IMPORTED) set_target_properties(mpv PROPERTIES 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_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. 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_libraries(pstr_mpv PRIVATE mpv android EGL log) +target_link_libraries(pstr_mpv PRIVATE mpv avcodec android EGL log) diff --git a/android/app/src/main/cpp/pstr_mpv.cpp b/android/app/src/main/cpp/pstr_mpv.cpp index c8923d9..c2f8bb2 100644 --- a/android/app/src/main/cpp/pstr_mpv.cpp +++ b/android/app/src/main/cpp/pstr_mpv.cpp @@ -9,6 +9,10 @@ #include #include +extern "C" { +#include +} + #include #include #include @@ -212,7 +216,15 @@ class Player { option("vo", "libmpv"); option("force-window", "no"); 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-secs", "30"); option("demuxer-readahead-secs", "30"); @@ -546,8 +558,16 @@ class Player { /// A failure cascades — one unreadable header produces a dozen follow-on /// complaints — and the first line is the one that names the cause. Cleared /// 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) { if (!message.text) return; + if (message.prefix && !std::strncmp(message.prefix, "ffmpeg", 6)) return; std::lock_guard lock(state_mutex_); if (!error_.empty()) return; error_.assign(message.text); @@ -768,6 +788,22 @@ void reject_null_surface(JNIEnv *env) noexcept { } // 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 { return c_boundary(0, [] { auto player = std::make_unique(); 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 857e5f2..31a0abb 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row 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.width 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.LazyRow import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.foundation.text.KeyboardOptions @@ -127,6 +132,9 @@ import io.narl.protonstream.playback.PlayerScreen */ 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. */ private data class PlayRequest( val title: TitleRecord, @@ -222,6 +230,16 @@ fun ProtonStreamApp( destination = Destination.Library } 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()) { AnimatedContent(destination, label = "primary navigation") { target -> when (target) { @@ -235,7 +253,7 @@ fun ProtonStreamApp( playerMinimized = false }, { selectedTitleKey = it.key }, - padding, + body, ) } else { TitleScreen( @@ -250,7 +268,7 @@ fun ProtonStreamApp( model::reportError, model::reloadAfterMetadataChange, model::setWatched, - padding, + body, ) } Destination.Shares -> SharesScreen( @@ -259,7 +277,7 @@ fun ProtonStreamApp( model::repairShare, model::refreshShare, model::removeShare, - padding, + body, ) Destination.Downloads -> DownloadsScreen( state, @@ -267,7 +285,7 @@ fun ProtonStreamApp( model::pauseDownload, model::resumeDownload, model::deletePartial, - padding, + body, ) Destination.Settings -> SettingsScreen( state, @@ -275,7 +293,7 @@ fun ProtonStreamApp( { model.matchTitles(force = true) }, model::clearBlockCache, model::removeAllOffline, - padding, + body, ) } } @@ -557,7 +575,14 @@ private fun TitleScreen( modifier = Modifier.padding(top = 6.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 // part-watched, else the first unwatched episode, else the // first — the desktop client's `next_up`. @@ -579,8 +604,10 @@ private fun TitleScreen( enabled = playerReady && playlist.isNotEmpty(), ) { Text("Start over") } } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + // The rest in the same flow, and all the same kind of button: + // 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) }) { Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.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 // check that the thing the app matched is the thing they have. title.externalUrl?.let { url -> - TextButton(onClick = { + OutlinedButton(onClick = { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }.onFailure(onPreferenceError) @@ -648,37 +675,57 @@ private fun TitleScreen( } title.seasons.forEach { season -> 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( Modifier .fillMaxWidth() .padding(top = 12.dp) + .clip(MaterialTheme.shapes.medium) .clickable { expandedSeasons = if (isExpanded) expandedSeasons - season.label else expandedSeasons + season.label } - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(vertical = 8.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (isExpanded) "Collapse" else "Expand", + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + 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) }) { - Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("Download season") + IconButton(onClick = { DownloadCoordinator.enqueue(context, season.episodes) }) { + Icon( + Icons.Default.Download, + contentDescription = "Download ${season.label}", + ) } } } if (isExpanded) { - items(season.episodes, key = { it.linkId }) { episode -> + itemsIndexed(season.episodes, key = { _, it -> it.linkId }) { position, episode -> EpisodeRow( episode = episode, + numbering = episode.numbering(season.number, position), download = downloads.firstOrNull { 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. * - * A row carries the things a viewer acts on — resume, mark seen, download, - * pause — so they are controls, not a menu: the download button becomes the - * progress readout while it runs, which is the same space either way. + * The row *is* the play button — the still, the numbering and the name are one + * target, which is what lets the trailing controls be two icons in a fixed + * 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 private fun EpisodeRow( episode: EpisodeRecord, + numbering: String, download: RetainedDownload?, playerReady: Boolean, onPlay: () -> Unit, @@ -723,43 +794,77 @@ private fun EpisodeRow( ) { val running = download?.status == RetainedDownload.STATUS_RUNNING || download?.status == RetainedDownload.STATUS_QUEUED - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { + // 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 + // 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) { - // The still, and the largest target on the row for the thing the - // row is for. With metadata off the provider has no still, and - // Proton's own thumbnail is a frame of this very episode. - RemoteArtwork( - episode.stillUrl, - episode.providerName ?: episode.label, + // With metadata off the provider has no still, and Proton's own + // thumbnail is a frame of this very episode. + Box( Modifier .width(96.dp) .aspectRatio(16f / 9f) - .clip(MaterialTheme.shapes.small) - .clickable(enabled = playerReady, onClick = onPlay), - fallback = episode.thumbnailSource, - ) - Spacer(Modifier.width(12.dp)) - Button(onClick = onPlay, enabled = playerReady) { - Text( - when { - !playerReady -> "Player loading…" - episode.watched -> "Replay" - episode.resumeAt != null -> "Resume" - else -> "Play" - }, + .clip(MaterialTheme.shapes.small), + contentAlignment = Alignment.Center, + ) { + RemoteArtwork( + episode.stillUrl, + name, + Modifier.fillMaxSize(), + fallback = episode.thumbnailSource, + ) + // Over the still rather than beside it: the row already + // plays on tap, so this says so without spending a column. + 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)) { - 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( - episode.providerName ?: episode.detail, - style = MaterialTheme.typography.bodySmall, + numbering, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, 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 // half-watched episode they are done with, or one the 90 % rule @@ -775,12 +880,25 @@ private fun EpisodeRow( }, ) } - when { - episode.offline -> Text("Offline", style = MaterialTheme.typography.labelMedium) - running -> FilledTonalButton(onClick = { onPause(download!!) }) { Text("Pause") } - download != null -> FilledTonalButton(onClick = { onResume(download) }) { Text("Resume") } - else -> FilledTonalButton(onClick = onDownload) { - Icon(Icons.Default.Download, contentDescription = "Download", modifier = Modifier.size(18.dp)) + // One slot, always the same width, whatever state the download + // is in — including "there is nothing to say", where an empty + // box keeps the rows above and below it aligned. + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + when { + 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() }} · " + "${formatBytes(active.downloaded.toULong())} of ${formatBytes(active.total.toULong())}", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt index b540b77..8b03839 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Theme.kt @@ -67,7 +67,12 @@ private fun schemeOf(palette: PaletteRecord): ColorScheme { onPrimaryContainer = color(palette.text), secondary = color(palette.accentAlt), 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), background = color(palette.background), onBackground = color(palette.text), diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 892d40a..fcfc5ad 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -993,7 +993,7 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec Vec String { let stripped = episode - .strip_prefix(series) - .map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':'])) + .get(..series.len()) + .filter(|head| head.eq_ignore_ascii_case(series)) + .map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':'])) .unwrap_or(episode) .trim(); @@ -1642,6 +1653,47 @@ mod tests { 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 /// titled individually rather than by the collection. #[test] diff --git a/docs/BUGS.md b/docs/BUGS.md index cae589e..3a994d6 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -146,6 +146,119 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places). ## 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 **Symptom.** Removing a share left its authenticated visitor client usable for diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 5e4b7a2..deb05fe 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -14,7 +14,7 @@ # builds from the registry alone. pkgname=proton-stream -pkgver=0.2.0 +pkgver=0.3.0 pkgrel=1 pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" arch=('x86_64') diff --git a/scripts/build-libmpv-android.sh b/scripts/build-libmpv-android.sh index a511500..5761d45 100644 --- a/scripts/build-libmpv-android.sh +++ b/scripts/build-libmpv-android.sh @@ -171,6 +171,10 @@ stage_shared_dependency_closure() { mkdir -p "$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 x86_64 x86_64 x86_64-linux-android