mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(app,android,ios): end an episode without chapters as well as one with them
The up-next countdown only ran over a credits chapter, so a file with no chapters - most that are not anime - cut straight into the next episode with no warning and no way to stay. Such a file now shows the same card over its last ten seconds, counted on the file's own clock so it reaches zero at the end of file, and its second button is Cancel rather than Watch till the end. On the phones, an episode that ends with nothing after it - the last one, autoplay off, or cancelled - left a black screen. It now says so, with Play next or Watch again, and Close. Android also: - saves the resume position from PlaybackService rather than the player screen. With background audio on the service outlives the activity, and a position only the screen kept stopped being saved when it went. A swipe from Recents saves and stops instead of playing on with nothing to return to. - shows "Opening <episode>..." while the stream opens, where there was a black screen for as long as the share session and block list took.
This commit is contained in:
1 parent
c5490ab3da
commit
c6b0457c4d
7 files changed
+311
-36
No files matched your search
@@ -19,9 +19,11 @@ import android.media.session.MediaSession
|
|||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import io.narl.protonstream.MainActivity
|
import io.narl.protonstream.MainActivity
|
||||||
import io.narl.protonstream.R
|
import io.narl.protonstream.R
|
||||||
|
import io.narl.protonstream.native.NativeRuntime
|
||||||
import io.narl.protonstream.settings.SettingsStore
|
import io.narl.protonstream.settings.SettingsStore
|
||||||
import io.narl.protonstream.ui.loadArtwork
|
import io.narl.protonstream.ui.loadArtwork
|
||||||
import io.narl.protonstream.ui.loadThumbnail
|
import io.narl.protonstream.ui.loadThumbnail
|
||||||
@@ -33,6 +35,8 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
/** Owns libmpv so audio survives Activity recreation and rotation. */
|
||||||
@@ -81,6 +85,9 @@ class PlaybackService : Service() {
|
|||||||
private var notified: NotificationShape? = null
|
private var notified: NotificationShape? = null
|
||||||
/** The pending teardown, held so an episode transition can call it off. */
|
/** The pending teardown, held so an episode transition can call it off. */
|
||||||
private var retire: Job? = null
|
private var retire: Job? = null
|
||||||
|
/** The open episode's latest reading, which [track] saves. */
|
||||||
|
private var lastReading: MpvPlaybackState? = null
|
||||||
|
private var lastSave = 0L
|
||||||
/** The episode's artwork, and the source key it was fetched for. */
|
/** The episode's artwork, and the source key it was fetched for. */
|
||||||
private var artwork: Pair<String, Bitmap>? = null
|
private var artwork: Pair<String, Bitmap>? = null
|
||||||
private val binder = PlaybackBinder()
|
private val binder = PlaybackBinder()
|
||||||
@@ -115,7 +122,7 @@ class PlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
mediaSession.isActive = true
|
mediaSession.isActive = true
|
||||||
}
|
}
|
||||||
onStateChanged = { updateSession(it) }
|
onStateChanged = { updateSession(it); track(it) }
|
||||||
onExplicitStop = { retirePlayback() }
|
onExplicitStop = { retirePlayback() }
|
||||||
}
|
}
|
||||||
mediaSession = MediaSession(this, "proton-stream").apply {
|
mediaSession = MediaSession(this, "proton-stream").apply {
|
||||||
@@ -164,6 +171,22 @@ class PlaybackService : Service() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swiped away from Recents. With background audio on, this service would
|
||||||
|
* otherwise play on with no screen to return to and no autoplay, so it
|
||||||
|
* saves where the episode got to and stops.
|
||||||
|
*/
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
lastReading?.let { reading ->
|
||||||
|
// Bounded and on this thread: the process may go as soon as the
|
||||||
|
// service stops, and a save launched into its scope would go with it.
|
||||||
|
runBlocking { withTimeoutOrNull(SAVE_ON_EXIT_MS) { withContext(Dispatchers.IO) { save(reading) } } }
|
||||||
|
}
|
||||||
|
host?.stop()
|
||||||
|
retirePlayback()
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
runCatching { unregisterReceiver(becomingNoisy) }
|
runCatching { unregisterReceiver(becomingNoisy) }
|
||||||
@@ -225,6 +248,52 @@ class PlaybackService : Service() {
|
|||||||
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
return audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the resume position from here rather than from the player screen.
|
||||||
|
*
|
||||||
|
* This service outlives the activity whenever background audio is on, and a
|
||||||
|
* position the screen alone kept stopped being saved the moment the screen
|
||||||
|
* went. Readings are attributed by [MpvPlaybackState.media], so an episode
|
||||||
|
* change saves the outgoing episode's last reading before the new one's.
|
||||||
|
*/
|
||||||
|
private fun track(state: MpvPlaybackState) {
|
||||||
|
val media = state.media ?: return
|
||||||
|
if (state.duration <= 0.0) return
|
||||||
|
val previous = lastReading
|
||||||
|
lastReading = state
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val due = when {
|
||||||
|
previous != null && previous.media != media -> {
|
||||||
|
scope.launch(Dispatchers.IO) { save(previous) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
state.ended -> previous?.ended != true
|
||||||
|
state.paused -> previous?.paused != true
|
||||||
|
else -> now - lastSave >= SAVE_EVERY_MS
|
||||||
|
}
|
||||||
|
if (due) {
|
||||||
|
lastSave = now
|
||||||
|
scope.launch(Dispatchers.IO) { save(state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun save(state: MpvPlaybackState) {
|
||||||
|
val media = state.media ?: return
|
||||||
|
// Played to the end, or near enough that what is left is credits. A
|
||||||
|
// failure or a stop also "ends", and recording either as watched would
|
||||||
|
// retire an episode nobody saw.
|
||||||
|
val watched = state.endReason == EndReason.Eof || state.position >= state.duration * WATCHED_FRACTION
|
||||||
|
runCatching {
|
||||||
|
NativeRuntime.engine().saveWatchState(
|
||||||
|
media.substringBeforeLast(':'),
|
||||||
|
media.substringAfterLast(':'),
|
||||||
|
state.position.coerceIn(0.0, state.duration),
|
||||||
|
state.duration,
|
||||||
|
watched,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateSession(state: MpvPlaybackState) {
|
private fun updateSession(state: MpvPlaybackState) {
|
||||||
refreshSession(state, host?.nowPlaying?.value)
|
refreshSession(state, host?.nowPlaying?.value)
|
||||||
if (state.ended) scheduleRetire() else cancelRetire()
|
if (state.ended) scheduleRetire() else cancelRetire()
|
||||||
@@ -419,6 +488,9 @@ class PlaybackService : Service() {
|
|||||||
private const val TRANSITION_GRACE_MS = 30_000L
|
private const val TRANSITION_GRACE_MS = 30_000L
|
||||||
/** How far the volume drops for a navigation prompt or a notification. */
|
/** How far the volume drops for a navigation prompt or a notification. */
|
||||||
private const val DUCK_FACTOR = 0.3
|
private const val DUCK_FACTOR = 0.3
|
||||||
|
private const val SAVE_EVERY_MS = 5_000L
|
||||||
|
/** How long a swipe from Recents may hold the main thread to save. */
|
||||||
|
private const val SAVE_ON_EXIT_MS = 2_000L
|
||||||
|
|
||||||
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
/** Catppuccin Mocha mauve, the accent the app is themed with. */
|
||||||
private const val ACCENT = 0xFFCBA6F7.toInt()
|
private const val ACCENT = 0xFFCBA6F7.toInt()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -71,6 +72,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalWindowInfo
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -107,6 +109,7 @@ import uniffi.pstr_android.EpisodeRecord
|
|||||||
import uniffi.pstr_android.TitleRecord
|
import uniffi.pstr_android.TitleRecord
|
||||||
import uniffi.pstr_android.TrackPreferencesRecord
|
import uniffi.pstr_android.TrackPreferencesRecord
|
||||||
import uniffi.pstr_android.skipOffer
|
import uniffi.pstr_android.skipOffer
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** How long the controls stay up after a tap. */
|
/** How long the controls stay up after a tap. */
|
||||||
@@ -116,7 +119,7 @@ private const val CONTROLS_SECONDS = 4L
|
|||||||
private const val UP_NEXT_SECONDS = 10
|
private const val UP_NEXT_SECONDS = 10
|
||||||
|
|
||||||
/** Past this much of an episode it counts as watched. */
|
/** Past this much of an episode it counts as watched. */
|
||||||
private const val WATCHED_FRACTION = 0.9
|
internal const val WATCHED_FRACTION = 0.9
|
||||||
|
|
||||||
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
/** The loudest this app plays. Matches `pstr_core::prefs`, which stores it. */
|
||||||
private const val MAX_VOLUME = 100f
|
private const val MAX_VOLUME = 100f
|
||||||
@@ -191,6 +194,10 @@ fun PlayerScreen(
|
|||||||
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
|
var retryFrom by remember(key) { mutableStateOf<Double?>(null) }
|
||||||
val session = remember(key, attempt) { RustStreamSession(episode) }
|
val session = remember(key, attempt) { RustStreamSession(episode) }
|
||||||
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
var playbackError by remember(key) { mutableStateOf<String?>(null) }
|
||||||
|
// From the click until mpv has the file: the share session, the revision
|
||||||
|
// and its block list all come first, and on a slow link that is seconds of
|
||||||
|
// black screen unless something says so.
|
||||||
|
var opening by remember(key, attempt) { mutableStateOf(true) }
|
||||||
|
|
||||||
LaunchedEffect(session, host) {
|
LaunchedEffect(session, host) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -229,6 +236,7 @@ fun PlayerScreen(
|
|||||||
nativeHost.setSpeed(startup.speed)
|
nativeHost.setSpeed(startup.speed)
|
||||||
session.transferToPlayer()
|
session.transferToPlayer()
|
||||||
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
}.onFailure { playbackError = it.message ?: "Playback failed" }
|
||||||
|
opening = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
// Per episode: the stream Rust holds open. The surface is the screen's, not
|
||||||
@@ -266,12 +274,8 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(key) {
|
// The five-second save is PlaybackService's, which outlives this screen;
|
||||||
while (true) {
|
// these are the moments only the screen sees.
|
||||||
delay(5_000)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DisposableEffect(key) { onDispose { save() } }
|
DisposableEffect(key) { onDispose { save() } }
|
||||||
|
|
||||||
val next = episodes.getOrNull(index + 1)
|
val next = episodes.getOrNull(index + 1)
|
||||||
@@ -323,8 +327,10 @@ fun PlayerScreen(
|
|||||||
|
|
||||||
// Autoplay fires on a clean end of file only, and only for this episode's
|
// Autoplay fires on a clean end of file only, and only for this episode's
|
||||||
// own reading — never on a failure, and never on the outgoing file's.
|
// own reading — never on a failure, and never on the outgoing file's.
|
||||||
|
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||||
|
var stay by remember(key) { mutableStateOf(false) }
|
||||||
LaunchedEffect(state.endReason, state.media) {
|
LaunchedEffect(state.endReason, state.media) {
|
||||||
if (state.endReason == EndReason.Eof && state.media == key && autoplay && next != null) advance()
|
if (state.endReason == EndReason.Eof && state.media == key && autoplay && !stay && next != null) advance()
|
||||||
}
|
}
|
||||||
// A file mpv could not play ends like any other. Saying so is the whole
|
// A file mpv could not play ends like any other. Saying so is the whole
|
||||||
// difference between a broken episode and an app that silently skipped one.
|
// difference between a broken episode and an app that silently skipped one.
|
||||||
@@ -491,21 +497,45 @@ fun PlayerScreen(
|
|||||||
) { Text(skip.label) }
|
) { Text(skip.label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file with no chapters has no credits to count over, so the card
|
||||||
|
// takes its last seconds instead: without it the next episode cut in
|
||||||
|
// with no warning and no way to stay. Counted on the file's own clock,
|
||||||
|
// so it reaches zero at the end of file, which is what advances.
|
||||||
|
val tailLeft = (state.duration - state.position).takeIf {
|
||||||
|
chapters.creditsStart == null && state.duration > UP_NEXT_SECONDS * 6 && it <= UP_NEXT_SECONDS
|
||||||
|
}
|
||||||
UpNextCard(
|
UpNextCard(
|
||||||
next = next,
|
next = next,
|
||||||
visible = autoplay && !state.paused && playingThis &&
|
visible = autoplay && !stay && !state.paused && !state.ended && playingThis &&
|
||||||
chapters.creditsStart?.let { state.position >= it } == true,
|
(tailLeft != null || chapters.creditsStart?.let { state.position >= it } == true),
|
||||||
|
secondsLeft = tailLeft?.let { ceil(it).toInt() },
|
||||||
onPlayNow = { advance() },
|
onPlayNow = { advance() },
|
||||||
|
onStay = { stay = true },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
.padding(end = 16.dp, bottom = if (showControls) 132.dp else 24.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Played to the end with nothing starting after it: the last episode,
|
||||||
|
// autoplay off, or the viewer stayed. A black screen said none of that.
|
||||||
|
if (playingThis && state.endReason == EndReason.Eof && (next == null || !autoplay || stay)) {
|
||||||
|
EndCard(
|
||||||
|
finished = episode.label,
|
||||||
|
show = title.displayName,
|
||||||
|
next = next,
|
||||||
|
onPlayNext = { advance() },
|
||||||
|
// mpv unloads a file at its end, so this opens it again.
|
||||||
|
onReplay = { retryFrom = 0.0; attempt++ },
|
||||||
|
onClose = onClose,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Only while nothing else explains the still picture: an error is the
|
// Only while nothing else explains the still picture: an error is the
|
||||||
// more useful thing to say, and a spinner over it reads as "still
|
// more useful thing to say, and a spinner over it reads as "still
|
||||||
// trying".
|
// trying".
|
||||||
if (state.stalled && playbackError == null) {
|
if ((opening || state.stalled) && playbackError == null) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
@@ -517,10 +547,13 @@ fun PlayerScreen(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
)
|
)
|
||||||
if (state.buffering) {
|
if (opening || state.buffering) {
|
||||||
Text(
|
Text(
|
||||||
state.cachePercent.takeIf { it > 0.0 }
|
when {
|
||||||
?.let { "Buffering… ${it.roundToInt()}%" } ?: "Buffering…",
|
opening -> "Opening ${episode.label}…"
|
||||||
|
state.cachePercent > 0.0 -> "Buffering… ${state.cachePercent.roundToInt()}%"
|
||||||
|
else -> "Buffering…"
|
||||||
|
},
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp),
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
@@ -1016,24 +1049,31 @@ private fun EpisodeChooser(
|
|||||||
*
|
*
|
||||||
* "Watch till the end" holds for the rest of the file: someone who wants the
|
* "Watch till the end" holds for the rest of the file: someone who wants the
|
||||||
* post-credits scene should have to say so once, not once every ten seconds.
|
* post-credits scene should have to say so once, not once every ten seconds.
|
||||||
|
*
|
||||||
|
* With [secondsLeft] the card counts down the end of a file that has no credits
|
||||||
|
* chapter, on the file's own clock; the end of file is what advances, and the
|
||||||
|
* second button is "Cancel", since there are no credits to watch.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpNextCard(
|
private fun UpNextCard(
|
||||||
next: EpisodeRecord?,
|
next: EpisodeRecord?,
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
secondsLeft: Int?,
|
||||||
onPlayNow: () -> Unit,
|
onPlayNow: () -> Unit,
|
||||||
|
onStay: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var held by remember(next?.linkId) { mutableStateOf(false) }
|
var held by remember(next?.linkId) { mutableStateOf(false) }
|
||||||
var remaining by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
var counted by remember(next?.linkId) { mutableStateOf(UP_NEXT_SECONDS) }
|
||||||
|
val remaining = secondsLeft ?: counted
|
||||||
val upNext = next
|
val upNext = next
|
||||||
val showing = visible && upNext != null && !held
|
val showing = visible && upNext != null && !held
|
||||||
LaunchedEffect(showing) {
|
LaunchedEffect(showing, secondsLeft == null) {
|
||||||
if (!showing) return@LaunchedEffect
|
if (!showing || secondsLeft != null) return@LaunchedEffect
|
||||||
remaining = UP_NEXT_SECONDS
|
counted = UP_NEXT_SECONDS
|
||||||
while (remaining > 0) {
|
while (counted > 0) {
|
||||||
delay(1_000)
|
delay(1_000)
|
||||||
remaining -= 1
|
counted -= 1
|
||||||
}
|
}
|
||||||
onPlayNow()
|
onPlayNow()
|
||||||
}
|
}
|
||||||
@@ -1096,10 +1136,51 @@ private fun UpNextCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (secondsLeft != null) {
|
||||||
|
QuietButton(onClick = { held = true; onStay() }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
QuietButton(onClick = { held = true }, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
Text("Watch till the end")
|
Text("Watch till the end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the player says once an episode ends and nothing starts after it. */
|
||||||
|
@Composable
|
||||||
|
private fun EndCard(
|
||||||
|
finished: String,
|
||||||
|
show: String,
|
||||||
|
next: EpisodeRecord?,
|
||||||
|
onPlayNext: () -> Unit,
|
||||||
|
onReplay: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier
|
||||||
|
.widthIn(max = 360.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.shapes.medium)
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (next == null) "That was the last episode of $show" else "Finished $finished",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Row(Modifier.padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (next != null) {
|
||||||
|
AccentButton(onClick = onPlayNext) { Text("Play next") }
|
||||||
|
} else {
|
||||||
|
QuietButton(onClick = onReplay) { Text("Watch again") }
|
||||||
|
}
|
||||||
|
QuietButton(onClick = onClose, modifier = Modifier.padding(start = 8.dp)) { Text("Close") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ pub struct UpNext {
|
|||||||
/// The viewer said to play this one out, or the countdown has already
|
/// The viewer said to play this one out, or the countdown has already
|
||||||
/// fired. Either way, do not ask again about this file.
|
/// fired. Either way, do not ask again about this file.
|
||||||
dismissed: bool,
|
dismissed: bool,
|
||||||
|
/// The card is counting down the last seconds of a file with no credits
|
||||||
|
/// chapter, rather than the credits. Dismissing it then also means "do not
|
||||||
|
/// start the next one", since there are no credits to watch till the end.
|
||||||
|
tail: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which way to step through a title's files.
|
/// Which way to step through a title's files.
|
||||||
@@ -1110,9 +1114,20 @@ impl App {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file with no chapters has no credits to count over, so the card
|
||||||
|
// takes its last seconds instead: without it the next episode cut in
|
||||||
|
// with no warning and no way to stay. Not on a clip shorter than a
|
||||||
|
// few countdowns, where the card would cover most of it.
|
||||||
|
let tail = !playback.knows_credits()
|
||||||
|
&& playback
|
||||||
|
.duration
|
||||||
|
.is_some_and(|duration| duration > UP_NEXT_SECONDS * 6.0)
|
||||||
|
&& playback
|
||||||
|
.remaining()
|
||||||
|
.is_some_and(|left| left <= UP_NEXT_SECONDS);
|
||||||
let counting = playback.loaded
|
let counting = playback.loaded
|
||||||
&& !playback.paused
|
&& !playback.paused
|
||||||
&& playback.in_credits()
|
&& (playback.in_credits() || tail)
|
||||||
&& has_next
|
&& has_next
|
||||||
&& autoplay
|
&& autoplay
|
||||||
&& !self.up_next.dismissed;
|
&& !self.up_next.dismissed;
|
||||||
@@ -1120,10 +1135,20 @@ impl App {
|
|||||||
self.up_next.started = None;
|
self.up_next.started = None;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
self.up_next.tail = tail;
|
||||||
|
|
||||||
let now = ctx.input(|input| input.time);
|
let now = ctx.input(|input| input.time);
|
||||||
let started = *self.up_next.started.get_or_insert(now);
|
let started = *self.up_next.started.get_or_insert(now);
|
||||||
let left = UP_NEXT_SECONDS - (now - started);
|
let left = if tail {
|
||||||
|
// Counted on the file's own clock, so it reaches zero at the end
|
||||||
|
// of file, which is what starts the next one.
|
||||||
|
playback.remaining().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
UP_NEXT_SECONDS - (now - started)
|
||||||
|
};
|
||||||
|
if tail && left <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if left <= 0.0 {
|
if left <= 0.0 {
|
||||||
// Latched, not cleared: the next file takes a moment to open, and
|
// Latched, not cleared: the next file takes a moment to open, and
|
||||||
// without this the countdown would fire again on every frame of
|
// without this the countdown would fire again on every frame of
|
||||||
@@ -1157,6 +1182,7 @@ impl App {
|
|||||||
left: (left / UP_NEXT_SECONDS) as f32,
|
left: (left / UP_NEXT_SECONDS) as f32,
|
||||||
caption: next.map(|target| target.caption()).unwrap_or_default(),
|
caption: next.map(|target| target.caption()).unwrap_or_default(),
|
||||||
still,
|
still,
|
||||||
|
tail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,6 +1254,16 @@ impl App {
|
|||||||
if !self.engine.playback_prefs().autoplay_next {
|
if !self.engine.playback_prefs().autoplay_next {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Cancelled from the end-of-file card: the viewer chose to stay.
|
||||||
|
let cancelled = self.up_next.tail
|
||||||
|
&& self.up_next.dismissed
|
||||||
|
&& self
|
||||||
|
.playback
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|playback| playback.id == self.up_next.playback_id);
|
||||||
|
if cancelled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(target) = self.adjacent(Adjacent::Next) else {
|
let Some(target) = self.adjacent(Adjacent::Next) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -338,6 +338,18 @@ impl Playback {
|
|||||||
(end > self.position + 0.5).then_some((label, end))
|
(end > self.position + 0.5).then_some((label, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the chapters say where the credits start. Most files that are
|
||||||
|
/// not anime have no chapters at all.
|
||||||
|
pub fn knows_credits(&self) -> bool {
|
||||||
|
self.credits_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds of the file left to play, once its length is known.
|
||||||
|
pub fn remaining(&self) -> Option<f64> {
|
||||||
|
self.duration
|
||||||
|
.map(|duration| (duration - self.position).max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the playhead is inside the run of credits and preview that ends
|
/// Whether the playhead is inside the run of credits and preview that ends
|
||||||
/// the file — what "the episode is over" means when a file states its
|
/// the file — what "the episode is over" means when a file states its
|
||||||
/// chapters.
|
/// chapters.
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ pub struct UpNextCard {
|
|||||||
pub caption: String,
|
pub caption: String,
|
||||||
/// Its still, once there is one.
|
/// Its still, once there is one.
|
||||||
pub still: Option<egui::TextureHandle>,
|
pub still: Option<egui::TextureHandle>,
|
||||||
|
/// Counting down the end of a file with no credits chapter, where the
|
||||||
|
/// second button cancels the next episode rather than deferring it.
|
||||||
|
pub tail: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Overlay {
|
impl Overlay {
|
||||||
@@ -566,11 +569,15 @@ fn up_next_card(
|
|||||||
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
actions.push(Action::PlayAdjacent(Adjacent::Next));
|
||||||
}
|
}
|
||||||
ui.add_space(theme::space::S);
|
ui.add_space(theme::space::S);
|
||||||
if ui
|
let (label, hint) = if card.tail {
|
||||||
.button("Watch till the end")
|
("Cancel", "Stay here — the next episode will not start")
|
||||||
.on_hover_text("Play this one out — nothing will be skipped")
|
} else {
|
||||||
.clicked()
|
(
|
||||||
{
|
"Watch till the end",
|
||||||
|
"Play this one out — nothing will be skipped",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ui.button(label).on_hover_text(hint).clicked() {
|
||||||
actions.push(Action::WatchToEnd);
|
actions.push(Action::WatchToEnd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ final class PlayerHost {
|
|||||||
private var retryFrom: Double?
|
private var retryFrom: Double?
|
||||||
/// When the current stall began; see `tick`.
|
/// When the current stall began; see `tick`.
|
||||||
private var stalledSince: Date?
|
private var stalledSince: Date?
|
||||||
|
/// Cancelled from the end-of-file card: the viewer chose to stay, so the
|
||||||
|
/// end of this file does not start the next one.
|
||||||
|
var stay = false
|
||||||
/// Read once per episode: a preference the viewer changes mid-episode
|
/// Read once per episode: a preference the viewer changes mid-episode
|
||||||
/// applies to the next one.
|
/// applies to the next one.
|
||||||
private(set) var autoSkip = false
|
private(set) var autoSkip = false
|
||||||
@@ -202,6 +205,14 @@ final class PlayerHost {
|
|||||||
onClosed?()
|
onClosed?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play the open episode again from the start. mpv unloads a file at its
|
||||||
|
/// end, so this opens it again rather than seeking.
|
||||||
|
func replay() {
|
||||||
|
guard episode != nil else { return }
|
||||||
|
retryFrom = 0
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the episode again from scratch — a new stream, through the
|
/// Open the episode again from scratch — a new stream, through the
|
||||||
/// bridge's session refresh — where the failed one stopped.
|
/// bridge's session refresh — where the failed one stopped.
|
||||||
func retry() {
|
func retry() {
|
||||||
@@ -217,6 +228,7 @@ final class PlayerHost {
|
|||||||
let resumeFrom = retryFrom
|
let resumeFrom = retryFrom
|
||||||
retryFrom = nil
|
retryFrom = nil
|
||||||
stalledSince = nil
|
stalledSince = nil
|
||||||
|
stay = false
|
||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
session?.close()
|
session?.close()
|
||||||
playbackError = nil
|
playbackError = nil
|
||||||
@@ -345,7 +357,7 @@ final class PlayerHost {
|
|||||||
guard media == key else { return }
|
guard media == key else { return }
|
||||||
save()
|
save()
|
||||||
// Autoplay fires on a clean end of file only — never on a failure.
|
// Autoplay fires on a clean end of file only — never on a failure.
|
||||||
if reason == .eof, autoplay, next != nil {
|
if reason == .eof, autoplay, !stay, next != nil {
|
||||||
advance()
|
advance()
|
||||||
} else if reason == .failed {
|
} else if reason == .failed {
|
||||||
playbackError = player?.problem ?? "This episode could not be played."
|
playbackError = player?.problem ?? "This episode could not be played."
|
||||||
|
|||||||
@@ -61,11 +61,24 @@ struct PlayerView: View {
|
|||||||
if let offer = host.offer, !host.autoSkip {
|
if let offer = host.offer, !host.autoSkip {
|
||||||
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
Button(offer.label) { host.seek(to: offer.target) }.buttonStyle(.tonal)
|
||||||
}
|
}
|
||||||
|
// A file with no chapters has no credits to count over, so
|
||||||
|
// the card takes its last seconds instead, on the file's
|
||||||
|
// own clock: the end of file is what advances.
|
||||||
|
let tailLeft: Double? = {
|
||||||
|
let left = player.duration - player.position
|
||||||
|
guard host.chapters.creditsStart == nil,
|
||||||
|
player.duration > Double(upNextSeconds * 6),
|
||||||
|
left <= Double(upNextSeconds) else { return nil }
|
||||||
|
return left
|
||||||
|
}()
|
||||||
UpNextCard(
|
UpNextCard(
|
||||||
next: host.next,
|
next: host.next,
|
||||||
visible: host.autoplay && !player.paused && host.playingOpenEpisode
|
visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil
|
||||||
&& host.chapters.creditsStart.map { player.position >= $0 } == true,
|
&& host.playingOpenEpisode
|
||||||
onPlayNow: host.advance
|
&& (tailLeft != nil || host.chapters.creditsStart.map { player.position >= $0 } == true),
|
||||||
|
secondsLeft: tailLeft.map { Int($0.rounded(.up)) },
|
||||||
|
onPlayNow: host.advance,
|
||||||
|
onStay: { host.stay = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
@@ -88,6 +101,13 @@ struct PlayerView: View {
|
|||||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Played to the end with nothing starting after it: the last
|
||||||
|
// episode, autoplay off, or the viewer stayed.
|
||||||
|
if host.playingOpenEpisode, player.endReason == .eof,
|
||||||
|
host.next == nil || !host.autoplay || host.stay {
|
||||||
|
EndCard(host: host)
|
||||||
|
}
|
||||||
|
|
||||||
if let message = host.playbackError ?? player.problem {
|
if let message = host.playbackError ?? player.problem {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
|
||||||
@@ -316,7 +336,11 @@ private struct CompactIcon: View {
|
|||||||
private struct UpNextCard: View {
|
private struct UpNextCard: View {
|
||||||
let next: EpisodeRecord?
|
let next: EpisodeRecord?
|
||||||
let visible: Bool
|
let visible: Bool
|
||||||
|
/// Counting the end of a file with no credits chapter, on the file's own
|
||||||
|
/// clock; the second button then cancels rather than deferring.
|
||||||
|
let secondsLeft: Int?
|
||||||
let onPlayNow: () -> Void
|
let onPlayNow: () -> Void
|
||||||
|
let onStay: () -> Void
|
||||||
@Environment(\.scheme) private var scheme
|
@Environment(\.scheme) private var scheme
|
||||||
@State private var held = false
|
@State private var held = false
|
||||||
@State private var remaining = upNextSeconds
|
@State private var remaining = upNextSeconds
|
||||||
@@ -340,10 +364,10 @@ private struct UpNextCard: View {
|
|||||||
Circle().fill(.black.opacity(0.5))
|
Circle().fill(.black.opacity(0.5))
|
||||||
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0, to: Double(remaining) / Double(upNextSeconds))
|
.trim(from: 0, to: Double(secondsLeft ?? remaining) / Double(upNextSeconds))
|
||||||
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
.stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||||
.rotationEffect(.degrees(-90))
|
.rotationEffect(.degrees(-90))
|
||||||
.animation(.linear(duration: 1), value: remaining)
|
.animation(.linear(duration: 1), value: secondsLeft ?? remaining)
|
||||||
Image(systemName: "play.fill").foregroundStyle(.white)
|
Image(systemName: "play.fill").foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 40, height: 40)
|
||||||
@@ -353,13 +377,14 @@ private struct UpNextCard: View {
|
|||||||
.buttonStyle(PressableStyle(wash: .white))
|
.buttonStyle(PressableStyle(wash: .white))
|
||||||
.accessibilityLabel("Play now")
|
.accessibilityLabel("Play now")
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
Text("Up next in \(secondsLeft ?? remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||||
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
Text(upNext.providerName ?? upNext.label).textStyle(.titleSmall).foregroundStyle(scheme.onSurface).lineLimit(2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Watch till the end") {
|
Button(secondsLeft == nil ? "Watch till the end" : "Cancel") {
|
||||||
held = true
|
held = true
|
||||||
heldFor = upNext.linkId
|
heldFor = upNext.linkId
|
||||||
|
if secondsLeft != nil { onStay() }
|
||||||
}
|
}
|
||||||
.buttonStyle(.quiet)
|
.buttonStyle(.quiet)
|
||||||
}
|
}
|
||||||
@@ -367,6 +392,7 @@ private struct UpNextCard: View {
|
|||||||
.frame(width: 280)
|
.frame(width: 280)
|
||||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
.task(id: upNext.linkId) {
|
.task(id: upNext.linkId) {
|
||||||
|
guard secondsLeft == nil else { return }
|
||||||
remaining = upNextSeconds
|
remaining = upNextSeconds
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
try? await Task.sleep(for: .seconds(1))
|
try? await Task.sleep(for: .seconds(1))
|
||||||
@@ -380,6 +406,35 @@ private struct UpNextCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the player says once an episode ends and nothing starts after it.
|
||||||
|
private struct EndCard: View {
|
||||||
|
let host: PlayerHost
|
||||||
|
@Environment(\.scheme) private var scheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text(host.next == nil
|
||||||
|
? "That was the last episode of \(host.title?.displayName ?? "this title")"
|
||||||
|
: "Finished \(host.episode?.label ?? "this episode")")
|
||||||
|
.textStyle(.titleMedium)
|
||||||
|
.foregroundStyle(scheme.onSurface)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if host.next != nil {
|
||||||
|
Button("Play next", action: host.advance).buttonStyle(.accent)
|
||||||
|
} else {
|
||||||
|
Button("Watch again", action: host.replay).buttonStyle(.quiet)
|
||||||
|
}
|
||||||
|
Button("Close", action: host.close).buttonStyle(.quiet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
|
||||||
|
.padding(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SpeedSheet: View {
|
private struct SpeedSheet: View {
|
||||||
let current: Double
|
let current: Double
|
||||||
let onSelect: (Double) -> Void
|
let onSelect: (Double) -> Void
|
||||||
|
|||||||
Reference in new issue
Block a user