mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Most files that are not fansub releases carry no chapters, so they had no skip button. AniSkip knows where an episode's opening and ending are, by MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList id and enrichment now keeps it on every episode row; schema v14 adds the column and marks AniList titles un-enriched, deleting nothing, as v13 did. When a file has loaded with no chapter naming an opening or ending, and its length is known, each client asks once, with enrichment on only. The answer lives as long as the file is open. with_skip_ranges cuts it into the chapters as chapters named Opening and Ending, so the skip button, auto-skip, the up-next countdown and the seek bar's marks all work through the code a chaptered file uses. A file's own opening or ending always wins over AniSkip's. PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and the file's length.
705 lines
30 KiB
Swift
705 lines
30 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 close to the end the next episode's opening blocks are fetched.
|
|
private let warmNextSeconds = 120.0
|
|
|
|
/// 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>()
|
|
/// The episode whose next one has been warmed; see `tick`.
|
|
@ObservationIgnored private var warmedAfter: String?
|
|
/// The episode AniSkip has been asked about, and what it said.
|
|
@ObservationIgnored private var skipsAskedFor: String?
|
|
@ObservationIgnored private var skipRanges: (key: String, ranges: [SkipRangeRecord])?
|
|
@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() }
|
|
player?.onLoaded = { [weak self] in self?.preferFullSubtitles() }
|
|
}
|
|
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)
|
|
// While mpv opens the episode; the player holds them until it has.
|
|
let generation = player.generation
|
|
let subtitleLanguage = show?.subtitleLanguage ?? global.subtitleLanguage
|
|
let subtitles = show?.subtitles ?? global.subtitles
|
|
Task {
|
|
guard let files = try? await NativeRuntime.engine().openSubtitles(
|
|
shareId: episode.shareId, linkId: episode.linkId, language: subtitleLanguage
|
|
) else { return }
|
|
player.addSubtitles(generation: generation, files: files, subtitles: subtitles)
|
|
}
|
|
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)
|
|
}
|
|
// Near the end, fetch the next episode's opening blocks, so autoplay
|
|
// or "Play now" starts from the cache. The last two minutes or the
|
|
// credits, whichever comes first: late enough that stopping after one
|
|
// episode has not cost a download, early enough to matter.
|
|
let nearEnd = player.duration > 0 && (
|
|
player.duration - player.position < warmNextSeconds
|
|
|| chapters.creditsStart.map { player.position >= $0 } == true
|
|
)
|
|
// A file whose chapters name no opening or ending asks AniSkip, once,
|
|
// when its length is known. Only with posters and descriptions on:
|
|
// anything else, an error included, leaves the file as its chapters
|
|
// made it.
|
|
if player.duration > 0, let key, skipsAskedFor != key, let title, let episode {
|
|
skipsAskedFor = key
|
|
let named = chapters.entries.contains { $0.kind == .opening || $0.kind == .ending }
|
|
if !named {
|
|
let (titleKey, shareId, linkId, duration) = (title.key, episode.shareId, episode.linkId, player.duration)
|
|
Task { [weak self] in
|
|
let ranges = (try? await NativeRuntime.engine().skipTimes(
|
|
titleKey: titleKey, shareId: shareId, linkId: linkId, duration: duration
|
|
)) ?? []
|
|
guard let self, !ranges.isEmpty, self.key == key, let player = self.player else { return }
|
|
self.skipRanges = (key, ranges)
|
|
self.plannedFor = nil
|
|
self.replanChapters(player)
|
|
}
|
|
}
|
|
}
|
|
if nearEnd, let key, warmedAfter != key, let next {
|
|
warmedAfter = key
|
|
let (shareId, volumeId, linkId) = (next.shareId, next.volumeId, next.linkId)
|
|
Task.detached {
|
|
try? await NativeRuntime.engine().warmEpisode(shareId: shareId, volumeId: volumeId, linkId: linkId)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
let skips = skipRanges.flatMap { $0.key == key ? $0.ranges : nil } ?? []
|
|
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil, skips: skips)
|
|
}
|
|
|
|
/// Play the dialogue rather than the signs and songs mpv picked by
|
|
/// language (`pstr_core::subtitles`). Not remembered: it is the choice mpv
|
|
/// should have made, not the viewer's.
|
|
private func preferFullSubtitles() {
|
|
guard let player, playingOpenEpisode else { return }
|
|
let subtitles = player.tracks.filter { $0.type == "sub" }.map {
|
|
SubtitleTrackRecord(id: $0.id, language: $0.language, title: $0.title, default: $0.isDefault, forced: $0.forced, selected: $0.selected)
|
|
}
|
|
guard let id = fullSubtitlesInstead(tracks: subtitles), let track = player.tracks.first(where: { $0.type == "sub" && $0.id == id }) else { return }
|
|
player.select(type: "sub", track: track)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|