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)
|
||||
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",
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user