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:
nvrl committed 2026-10-12 21:50:02 +02:00
1 parent c5490ab3da
commit c6b0457c4d
7 files changed
+313 -38

No files matched your search

+62 -7
View File
@@ -61,11 +61,24 @@ struct PlayerView: View {
if let offer = host.offer, !host.autoSkip {
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(
next: host.next,
visible: host.autoplay && !player.paused && host.playingOpenEpisode
&& host.chapters.creditsStart.map { player.position >= $0 } == true,
onPlayNow: host.advance
visible: host.autoplay && !host.stay && !player.paused && player.endReason == nil
&& host.playingOpenEpisode
&& (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)
@@ -88,6 +101,13 @@ struct PlayerView: View {
.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 {
VStack(spacing: 4) {
Text(message).foregroundStyle(scheme.onErrorContainer).multilineTextAlignment(.center)
@@ -316,7 +336,11 @@ private struct CompactIcon: View {
private struct UpNextCard: View {
let next: EpisodeRecord?
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 onStay: () -> Void
@Environment(\.scheme) private var scheme
@State private var held = false
@State private var remaining = upNextSeconds
@@ -340,10 +364,10 @@ private struct UpNextCard: View {
Circle().fill(.black.opacity(0.5))
Circle().stroke(.white.opacity(0.25), lineWidth: 3)
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))
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: remaining)
.animation(.linear(duration: 1), value: secondsLeft ?? remaining)
Image(systemName: "play.fill").foregroundStyle(.white)
}
.frame(width: 40, height: 40)
@@ -353,13 +377,14 @@ private struct UpNextCard: View {
.buttonStyle(PressableStyle(wash: .white))
.accessibilityLabel("Play now")
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)
}
}
Button("Watch till the end") {
Button(secondsLeft == nil ? "Watch till the end" : "Cancel") {
held = true
heldFor = upNext.linkId
if secondsLeft != nil { onStay() }
}
.buttonStyle(.quiet)
}
@@ -367,6 +392,7 @@ private struct UpNextCard: View {
.frame(width: 280)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.standard))
.task(id: upNext.linkId) {
guard secondsLeft == nil else { return }
remaining = upNextSeconds
while remaining > 0 {
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 {
let current: Double
let onSelect: (Double) -> Void