mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
improved android ui
This commit is contained in:
1 parent
f1b3ad9637
commit
46bb68e3a5
8 files changed
+396
-61
No files matched your search
@@ -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)
|
||||
@@ -9,6 +9,10 @@
|
||||
#include <mpv/render_gl.h>
|
||||
#include <mpv/stream_cb.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/jni.h>
|
||||
}
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
@@ -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<jlong>(0, [] {
|
||||
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.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",
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(season.label, style = MaterialTheme.typography.titleLarge)
|
||||
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,
|
||||
)
|
||||
}
|
||||
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),
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
RemoteArtwork(
|
||||
episode.stillUrl,
|
||||
name,
|
||||
Modifier.fillMaxSize(),
|
||||
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"
|
||||
},
|
||||
// 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(
|
||||
},
|
||||
)
|
||||
}
|
||||
// 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 -> 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))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'.
|
||||
if row.parsed.episode_title.is_none()
|
||||
&& !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);
|
||||
// …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.
|
||||
if naming::is_season_marker(&rest) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1086,10 +1090,17 @@ fn ancestry(
|
||||
///
|
||||
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
|
||||
/// `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 {
|
||||
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]
|
||||
|
||||
+113
@@ -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
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user