Files
proton-stream/ios/ProtonStream/Player/PlayerHost.swift
T
nvrl 0b9a9dab50 fix(ios,android): offer the right episode as up next while the next one opens
After "next episode" during the credits, the previous file keeps
playing until the new stream is open. Its chapters and position were
read against the already-advanced index, so the up-next card offered
the episode after the next one for those seconds. The card, the skip
offer and auto-skip now only act on readings of the open episode.
2026-10-12 18:36:33 +02:00

568 lines
23 KiB
Swift

import AVFoundation
import Foundation
import MediaPlayer
import Observation
import UIKit
/// How long the "up next" card counts down before it loads the next episode.
let upNextSeconds = 10
/// Past this much of an episode it counts as watched.
private let watchedFraction = 0.9
/// How an episode is named in watch state and player readings.
func mediaKey(_ episode: EpisodeRecord) -> String { "\(episode.shareId):\(episode.linkId)" }
/// The open episode's stream, from Rust: opened, handed to mpv, given back.
///
/// The native handle belongs to mpv once a load succeeds — `close_fn` in the
/// stream protocol releases it after mpv's last read. Only a load that never
/// happened leaves it here to release.
private final class StreamSession {
let episode: EpisodeRecord
private(set) var handle: UInt64?
private(set) var size: UInt64 = 0
init(episode: EpisodeRecord) {
self.episode = episode
}
func open() async throws {
let stream = try await NativeRuntime.engine().openStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
size = stream.size()
handle = stream.nativeHandle()
}
/// The player now owns the published handle until stop, end or close.
func transferToPlayer() {
handle = nil
}
/// Give the episode's stream back to Rust, from a task of its own: the one
/// moment this has to run is when the player moves on, and nothing that
/// goes away with it may cancel it.
func close() {
if let handle { pstr_android_stream_release(handle) }
handle = nil
let episode = episode
Task.detached {
try? await NativeRuntime.engine().releaseStream(shareId: episode.shareId, volumeId: episode.volumeId, linkId: episode.linkId)
}
}
}
/// What the player has open and everything that walks it: the stream per
/// episode, the resume position, autoplay, auto-skip, the lock screen and the
/// audio session.
///
/// Android splits this between `PlayerScreen`, `NativeMpvHost` and
/// `PlaybackService`. Here it is one object that outlives the player's view,
/// because the episode keeps playing while the view is minimised or the app
/// is in the background.
@MainActor
@Observable
final class PlayerHost {
static let shared = PlayerHost()
/// The core, once something has been played. Nil if libmpv would not start.
private(set) var player: MpvPlayer?
private(set) var unavailable = false
private(set) var title: TitleRecord?
private(set) var episodes: [EpisodeRecord] = []
private(set) var index = 0
/// Playing, but not on screen: the episode keeps going and the mini
/// transport is what says so.
var minimized = false
/// The open file's chapters, already resolved into openings and endings.
private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil)
/// What stopped this episode, until the viewer dismisses it.
var playbackError: String?
/// The stream is being opened: the share session, the revision and its
/// block list, all before mpv has a byte. On an old iPad that is seconds
/// of a black screen unless something says so.
private(set) var opening = false
/// Read once per episode: a preference the viewer changes mid-episode
/// applies to the next one.
private(set) var autoSkip = false
private(set) var autoplay = true
/// The rate playing, so the speed list opens on it.
var speed = 1.0
/// Persist where an episode was left: position, duration, watched.
@ObservationIgnored var onSaveProgress: ((EpisodeRecord, Double, Double, Bool) -> Void)?
/// The player closed: where it stopped is what another device wants next.
@ObservationIgnored var onClosed: (() -> Void)?
@ObservationIgnored private var session: StreamSession?
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var saveTask: Task<Void, Never>?
@ObservationIgnored private var plannedFor: (Double, Int)?
/// Chapters already auto-skipped in this episode: seeking back into an
/// opening on purpose must not be undone by the setting that skipped it.
@ObservationIgnored private var skipped = Set<Int64>()
@ObservationIgnored private var pausedByInterruption = false
@ObservationIgnored private var artworkFor: String?
private init() {
observeAudioSession()
configureRemoteCommands()
}
var episode: EpisodeRecord? { episodes.indices.contains(index) ? episodes[index] : nil }
var key: String? { episode.map(mediaKey) }
var next: EpisodeRecord? { episodes.indices.contains(index + 1) ? episodes[index + 1] : nil }
/// Whether mpv's readings — position, chapters — are the open episode's.
/// Not while the next one opens: the last one's credits go on playing
/// until it is loaded, and read against the new index they offered the
/// episode *after* the next as up next.
var playingOpenEpisode: Bool { player?.media != nil && player?.media == key }
var isOpen: Bool { title != nil }
// MARK: - Opening
/// Open a title's episodes at `index`, or move to it if the title is open.
func play(_ title: TitleRecord, at index: Int) {
let episodes = title.playlist
guard !episodes.isEmpty else { return }
let target = min(max(index, 0), episodes.count - 1)
// The same episode, still open: only bring it back on screen. One that
// has ended has nothing loaded, and is played again.
if self.title?.key == title.key, mediaKey(episodes[target]) == key, player?.endReason == nil {
self.title = title
self.episodes = episodes
minimized = false
return
}
save()
self.title = title
self.episodes = episodes
self.index = target
minimized = false
load()
}
/// Keep the open title's records current after the library reloads, so the
/// episode list shows what was just watched.
func update(titles: [TitleRecord]) {
guard let key = title?.key, let fresh = titles.first(where: { $0.key == key }) else { return }
let current = self.key
title = fresh
episodes = fresh.playlist
if let current, let moved = episodes.firstIndex(where: { mediaKey($0) == current }) { index = moved }
}
func advance() {
guard next != nil else { return }
move(to: index + 1)
}
func retreat() {
guard index > 0 else { return }
move(to: index - 1)
}
func jump(to target: Int) {
guard target != index, episodes.indices.contains(target) else { return }
move(to: target)
}
private func move(to target: Int) {
save()
index = target
load()
}
/// Stop playback and close the player. The mini transport's ✕ and the
/// player's own are the one place it is unambiguous.
func close() {
save()
loadTask?.cancel()
saveTask?.cancel()
player?.stop()
session?.close()
session = nil
title = nil
episodes = []
index = 0
minimized = false
playbackError = nil
opening = false
chapters = ChapterPlan(entries: [], creditsStart: nil)
OrientationLock.set(false)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
UIApplication.shared.isIdleTimerDisabled = false
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
onClosed?()
}
private func load() {
guard let title, let episode else { return }
loadTask?.cancel()
session?.close()
playbackError = nil
chapters = ChapterPlan(entries: [], creditsStart: nil)
plannedFor = nil
skipped = []
let session = StreamSession(episode: episode)
self.session = session
let key = mediaKey(episode)
let titleKey = title.key
publishNowPlaying()
if player == nil, !unavailable {
player = MpvPlayer.create()
unavailable = player == nil
player?.onEnded = { [weak self] reason, media in self?.ended(reason, media) }
player?.onTick = { [weak self] in self?.tick() }
player?.onStateChanged = { [weak self] in self?.publishNowPlaying() }
}
guard let player else {
playbackError = "Playback is unavailable: libmpv did not start."
return
}
try? AVAudioSession.sharedInstance().setActive(true)
opening = true
loadTask = Task {
do {
try await session.open()
let startup = try await Task.detached {
let engine = try NativeRuntime.blockingEngine()
// A choice made on this title wins; without one, the choice
// the viewer made anywhere else does — the desktop client's
// fallback chain.
let show = try engine.titleTrackPreferences(titleKey: titleKey)
let global = try engine.playbackPrefs()
let watch = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
return (show, global, watch)
}.value
if Task.isCancelled || self.session !== session {
abandon(session)
return
}
let (show, global, watch) = startup
guard let handle = session.handle, session.size > 0 else { throw PlaybackFailure("Cannot play an empty stream") }
autoSkip = global.autoSkip
autoplay = global.autoplayNext
speed = global.speed
let loaded = player.load(
media: key,
handle: handle,
// A finished episode restarts rather than resuming three
// seconds from its own credits.
start: watch.flatMap { $0.watched ? nil : $0.positionSecs } ?? 0,
audio: show?.audioLanguage ?? global.audioLanguage,
subtitle: show?.subtitleLanguage ?? global.subtitleLanguage,
subtitles: show?.subtitles ?? global.subtitles,
hardwareDecoding: SettingsStore().hardwareDecoding
)
guard loaded else { throw PlaybackFailure("libmpv rejected the stream") }
// mpv's own buffering takes over the spinner from here.
opening = false
session.transferToPlayer()
// After the load, because mpv keeps none of the three across one.
player.setVolume(global.volume)
player.setMuted(global.muted)
player.setSpeed(global.speed)
player.setPaused(false)
startSaving()
} catch {
if Task.isCancelled || self.session !== session {
abandon(session)
return
}
opening = false
playbackError = errorMessage(error)
}
}
}
/// A session the player moved past while it was still opening. Its
/// `close()` already ran, before there was a handle to release, so the one
/// it has published since is released here — and only that, since the
/// stream itself was already given back.
private func abandon(_ session: StreamSession) {
if let handle = session.handle {
pstr_android_stream_release(handle)
session.transferToPlayer()
}
}
private struct PlaybackFailure: LocalizedError {
let errorDescription: String?
init(_ message: String) { errorDescription = message }
}
// MARK: - Progress
/// Resume position, written only from readings that carry this episode's
/// name: an unattributed reading is the previous one's clock.
func save() {
guard let player, let episode, player.media == mediaKey(episode), player.duration > 0 else { return }
onSaveProgress?(
episode,
min(max(player.position, 0), player.duration),
player.duration,
// Watched means played to the end. An episode that was stopped, or
// that mpv could not play at all, also "ended" — recording either
// as watched retires an episode nobody saw.
player.endReason == .eof || player.position >= player.duration * watchedFraction
)
}
private func startSaving() {
saveTask?.cancel()
saveTask = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(5))
if Task.isCancelled { return }
save()
}
}
}
// MARK: - Reacting to the player
private func ended(_ reason: EndReason, _ media: String) {
guard media == key else { return }
save()
// Autoplay fires on a clean end of file only — never on a failure.
if reason == .eof, autoplay, next != nil {
advance()
} else if reason == .failed {
playbackError = player?.problem ?? "This episode could not be played."
}
publishNowPlaying()
}
private func tick() {
guard let player, playingOpenEpisode else { return }
replanChapters(player)
// Auto-skip, once per chapter.
if autoSkip, !player.paused, let offer = skipOffer(plan: chapters, position: player.position),
let chapter = chapters.entries.last(where: { player.position + 0.001 >= $0.start }),
skipped.insert(chapter.index).inserted {
player.seek(to: offer.target)
}
}
/// Re-read the chapters when there is a reason to: a different file, or a
/// duration that has only just arrived. Both change the answer — the length
/// of a chapter is what settles whether `Intro` is an opening or ten minutes
/// of story — and neither changes again after that.
private func replanChapters(_ player: MpvPlayer) {
let wanted = (player.duration, player.generation)
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
plannedFor = wanted
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
}
/// The skip button's offer at the current position, if any.
var offer: SkipOffer? {
guard let player, playingOpenEpisode else { return nil }
return skipOffer(plan: chapters, position: player.position)
}
// MARK: - Transport
func setPaused(_ paused: Bool) {
player?.setPaused(paused)
if !paused { try? AVAudioSession.sharedInstance().setActive(true) }
publishNowPlaying()
}
func seek(to seconds: Double) {
player?.seek(to: seconds)
updateElapsed(seconds)
}
/// Persist a volume or a mute, leaving whichever was not given as it was.
/// Written when the thumb is released rather than while it moves.
func rememberVolume(volume: Double? = nil, muted: Bool? = nil) {
Task.detached {
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
prefs.volume = volume ?? prefs.volume
prefs.muted = muted ?? prefs.muted
try? engine.setPlaybackPrefs(prefs: prefs)
}
}
/// Persist a playback rate, so the next episode starts at it.
func setSpeed(_ rate: Double) {
speed = rate
player?.setSpeed(rate)
publishNowPlaying()
Task.detached {
guard let engine = try? NativeRuntime.blockingEngine(), var prefs = try? engine.playbackPrefs() else { return }
prefs.speed = rate
try? engine.setPlaybackPrefs(prefs: prefs)
}
}
/// A track for this title. The choice is the title's from now on.
func select(type: String, track: MpvTrack?) {
player?.select(type: type, track: track)
guard let titleKey = title?.key else { return }
Task.detached {
guard let engine = try? NativeRuntime.blockingEngine(), let global = try? engine.playbackPrefs() else { return }
// No per-title choice yet: start from what plays everywhere else,
// so choosing an audio track does not silently drop the subtitle
// preference this episode started with.
var preferences = (try? engine.titleTrackPreferences(titleKey: titleKey)) ?? TrackPreferencesRecord(
audioLanguage: global.audioLanguage,
subtitleLanguage: global.subtitleLanguage,
subtitles: global.subtitles
)
let language = track.flatMap { $0.language.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0.language }
if type == "audio" {
preferences.audioLanguage = language
} else {
// Turning subtitles off keeps the language it was off *from*, so
// turning them back on does not have to be told it again.
preferences.subtitleLanguage = language ?? preferences.subtitleLanguage
preferences.subtitles = track != nil
}
try? engine.setTitleTrackPreferences(titleKey: titleKey, preferences: preferences)
}
}
func dismissProblem() {
playbackError = nil
player?.problem = nil
}
// MARK: - Background
/// Leaving the screen: the picture goes, the sound stays if the viewer
/// asked for background audio, and the position is saved either way.
func enteredBackground() {
save()
guard isOpen, let player else { return }
if SettingsStore().backgroundAudio {
player.setVideoEnabled(false)
} else {
player.setPaused(true)
}
publishNowPlaying()
}
func enteredForeground() {
player?.setVideoEnabled(true)
}
// MARK: - Lock screen
/// What the lock screen and Control Center show: the episode, its show and
/// its art — never just the app.
private func publishNowPlaying() {
guard let title, let episode else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
return
}
let center = MPNowPlayingInfoCenter.default()
var info = center.nowPlayingInfo ?? [:]
info[MPMediaItemPropertyTitle] = episode.label
info[MPMediaItemPropertyArtist] = title.displayName
info[MPMediaItemPropertyAlbumTitle] = title.displayName
info[MPNowPlayingInfoPropertyMediaType] = MPNowPlayingInfoMediaType.video.rawValue
info[MPMediaItemPropertyPlaybackDuration] = player?.duration ?? 0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player?.position ?? 0
info[MPNowPlayingInfoPropertyPlaybackRate] = (player?.paused ?? true) ? 0.0 : speed
center.nowPlayingInfo = info
let commands = MPRemoteCommandCenter.shared()
commands.nextTrackCommand.isEnabled = next != nil
commands.previousTrackCommand.isEnabled = index > 0
UIApplication.shared.isIdleTimerDisabled = !(player?.paused ?? true) && !minimized
let artKey = mediaKey(episode)
if artworkFor != artKey {
artworkFor = artKey
info[MPMediaItemPropertyArtwork] = nil
center.nowPlayingInfo = info
let url = title.posterUrl ?? title.backdropUrl
let source = episode.thumbnailSource
Task {
guard let image = await ArtworkLoader.shared.artwork(url: url, fallback: source), artworkFor == artKey else { return }
var current = center.nowPlayingInfo ?? [:]
current[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
center.nowPlayingInfo = current
}
}
}
private func updateElapsed(_ seconds: Double) {
let center = MPNowPlayingInfoCenter.default()
guard var info = center.nowPlayingInfo else { return }
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = seconds
center.nowPlayingInfo = info
}
private func configureRemoteCommands() {
let commands = MPRemoteCommandCenter.shared()
commands.playCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.setPaused(false) }
return .success
}
commands.pauseCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.setPaused(true) }
return .success
}
commands.togglePlayPauseCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.setPaused(!(self?.player?.paused ?? true)) }
return .success
}
commands.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
MainActor.assumeIsolated { self?.seek(to: event.positionTime) }
return .success
}
commands.nextTrackCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.advance() }
return .success
}
commands.previousTrackCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.retreat() }
return .success
}
commands.stopCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.close() }
return .success
}
}
/// A call or an alarm pauses, and its end resumes only what this paused —
/// Android's transient focus loss. Headphones coming out pause, as
/// `BECOMING_NOISY` does there.
private func observeAudioSession() {
let center = NotificationCenter.default
center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: raw)
else { return }
let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt).map(AVAudioSession.InterruptionOptions.init)
MainActor.assumeIsolated {
guard let self, let player = self.player, self.isOpen else { return }
switch type {
case .began:
if !player.paused {
self.pausedByInterruption = true
self.setPaused(true)
}
case .ended:
if self.pausedByInterruption, options?.contains(.shouldResume) == true { self.setPaused(false) }
self.pausedByInterruption = false
@unknown default:
break
}
}
}
center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable
else { return }
MainActor.assumeIsolated {
guard let self, self.isOpen else { return }
self.setPaused(true)
}
}
}
}