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
+402
-67
No files matched your search
@@ -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)
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user