Files
proton-stream/ios/ProtonStream/Player/PlayerHost.swift
T
nvrl 7994097a25 fix(ios): keep the screen awake under video and the lock screen honest
- The idle timer was recomputed only when Now Playing was republished,
  so minimising and then reopening a playing episode let the screen
  sleep under it. It now follows `minimized` too.
- With background audio off, leaving the app paused but left video on,
  so lock-screen Play started decoding into an unseen layer. Video goes
  off on the way out either way.
- Now Playing reported full speed through a stall, and its clock ran on.
  Buffering now reports a rate of zero.
- The lock screen gains ten-second skip forward and back.
2026-10-12 22:39:19 +02:00

638 lines
26 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 long a stall lasts before the player offers to open the episode again.
private let stallLimit: TimeInterval = 20
/// 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 mini transport has no picture to keep awake for; back on screen
// and still playing, it has one again. Nothing else recomputes this
// until the next pause, so the screen slept under the video.
didSet { updateIdleTimer() }
}
/// 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
/// Where a retry reopens the episode, when it got that far.
private var retryFrom: Double?
/// When the current stall began; see `tick`.
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
/// 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?()
}
/// 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
/// bridge's session refresh — where the failed one stopped.
func retry() {
guard episode != nil else { return }
if let player, player.media == key, player.position > 0 { retryFrom = player.position }
player?.problem = nil
load()
}
private func load() {
guard let title, let episode else { return }
// Taken once: only the load a retry asked for starts from it.
let resumeFrom = retryFrom
retryFrom = nil
stalledSince = nil
stay = false
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: resumeFrom ?? 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, !stay, 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)
// A stall that does not clear is a dropped connection far more often
// than a slow one, and "Buffering…" for ever offers no way out of it.
if player.buffering, !player.paused {
let since = stalledSince ?? .now
stalledSince = since
if playbackError == nil, Date.now.timeIntervalSince(since) >= stallLimit {
playbackError = "Still loading. The connection may have dropped."
}
} else {
stalledSince = nil
}
// 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 }
// The picture goes either way: with background audio off the lock
// screen's Play can still start the file, and decoding video into a
// layer nobody sees is work iOS ends a process for.
player.setVideoEnabled(false)
if !SettingsStore().backgroundAudio {
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
// Zero while it waits on the network too, or the lock screen's clock
// runs on through a stall.
let moving = !(player?.paused ?? true) && !(player?.buffering ?? false)
info[MPNowPlayingInfoPropertyPlaybackRate] = moving ? speed : 0.0
center.nowPlayingInfo = info
let commands = MPRemoteCommandCenter.shared()
commands.nextTrackCommand.isEnabled = next != nil
commands.previousTrackCommand.isEnabled = index > 0
updateIdleTimer()
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
}
}
}
/// Keep the screen on while a picture is playing on it, and only then.
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled = isOpen && !(player?.paused ?? true) && !minimized
}
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
}
// Ten seconds either way: what a video's lock-screen transport offers
// in place of a music player's previous and next.
commands.skipForwardCommand.preferredIntervals = [10]
commands.skipForwardCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.seek(to: (self?.player?.position ?? 0) + 10) }
return .success
}
commands.skipBackwardCommand.preferredIntervals = [10]
commands.skipBackwardCommand.addTarget { [weak self] _ in
MainActor.assumeIsolated { self?.seek(to: max((self?.player?.position ?? 0) - 10, 0)) }
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)
}
}
}
}