Files
nvrl 3fe4900337 fix(android,ios): the track tick moves when a track is chosen, and no file inherits another's languages
Choosing an audio or subtitle track read the track list back before mpv
had made the switch, so the tick stayed on the old track until the next
poll. The tick now moves on the choice, and the list is read again
300 ms later to confirm it.

On Android, `load` set `alang` and `slang` only when they were not
empty. mpv keeps both across `loadfile`, so a file with no language
preference took the previous file's. Both are now set on every load.
This closes the last open item of B41.
2026-10-31 03:26:39 +01:00

602 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Libmpv
import Observation
import UIKit
/// MoltenVK can set the drawable to 1x1 to force a presentation through, which
/// flickers and can stick. MPVKit's demo carries the same guard
/// (mpv-player/mpv#13651).
final class MpvMetalLayer: CAMetalLayer {
override var drawableSize: CGSize {
get { super.drawableSize }
set {
if Int(newValue.width) > 1, Int(newValue.height) > 1 {
super.drawableSize = newValue
}
}
}
}
/// Why the open file ended, mirroring `pstr_player::EndReason` so every client
/// agrees on what "finished" means.
///
/// Only `eof` is an episode watched to the end. Treating the others as one — a
/// stop, a shutdown, a failed demux — is what marks an episode that never
/// played as watched and autoplays past it.
enum EndReason {
case eof, stopped, quit, failed, other
}
struct MpvTrack: Identifiable, Hashable {
let id: Int64
let type: String
let language: String
let title: String
var selected: Bool
/// The container marks it as the default choice.
var isDefault = false
/// The container marks it as forced: on-screen text only, by convention.
var forced = false
var label: String {
let parts = [language, title].filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
return parts.isEmpty ? "\(type.prefix(1).uppercased() + type.dropFirst()) \(id)" : parts.joined(separator: " — ")
}
}
/// One libmpv core rendering into a Metal layer, for the life of the app.
///
/// Every public method runs on the main thread; mpv's events are read on a
/// thread of their own and applied back on the main thread. The state mirrors
/// what `pstr_mpv.cpp` publishes on Android, field for field.
@MainActor
@Observable
final class MpvPlayer {
private(set) var position: Double = 0
private(set) var duration: Double = 0
private(set) var volume: Double = 100
private(set) var paused = false
private(set) var muted = false
/// The file ended, and why. Nil while one is open.
private(set) var endReason: EndReason?
/// mpv has run out of buffered data and stopped to refill — not the same
/// as `paused`, which is the viewer's doing.
private(set) var buffering = false
/// How full the demuxer cache is while `buffering`, 0–100.
private(set) var cachePercent: Double = 0
/// A seek has been issued but playback has not resumed at the new position.
private(set) var seeking = false
/// How far the demuxer has read ahead, in seconds into the file.
private(set) var cachedUntil: Double = 0
/// The picture's display size, or zero until mpv has decoded enough.
private(set) var videoWidth: Double = 0
private(set) var videoHeight: Double = 0
private(set) var tracks: [MpvTrack] = []
private(set) var chapters: [ChapterRecord] = []
/// Which episode the readings above describe, or nil between `loadfile`
/// and mpv's `START_FILE`. Everything that *writes* per-episode state —
/// the resume position above all — must check it: one core serves every
/// episode, and a reading attributed to the wrong one resumes the next
/// episode where the last one stopped.
private(set) var media: String?
/// What mpv last complained about, until the viewer dismisses it.
var problem: String?
/// Bumped by every load, so a reader can tell "episode two, second zero"
/// from a leftover reading of episode one.
private(set) var generation = 0
/// Whether the picture is stopped for a reason the viewer did not choose.
var stalled: Bool { (buffering || seeking) && !paused && endReason == nil }
/// Called once per file that ends, never for the one a load replaced.
@ObservationIgnored var onEnded: ((EndReason, String) -> Void)?
/// Called on every position change, for what has to react to the clock.
@ObservationIgnored var onTick: (() -> Void)?
/// Called when the duration or the pause state changes: what the lock
/// screen shows, and whether the screen may sleep.
@ObservationIgnored var onStateChanged: (() -> Void)?
/// Called once per file, when mpv has loaded it and picked its tracks.
@ObservationIgnored var onLoaded: (() -> Void)?
@ObservationIgnored let layer = MpvMetalLayer()
@ObservationIgnored private var mpv: OpaquePointer?
/// Between `loadfile` and `START_FILE`. Everything mpv reports in that
/// window still describes the *outgoing* file, including the
/// `END_FILE(stop)` that stopping it emits.
@ObservationIgnored private var loading = false
@ObservationIgnored private var pendingMedia: String?
/// The load generation whose file mpv has loaded.
@ObservationIgnored private var loadedGeneration = -1
/// Subtitle files handed over before their file loaded, and whether each
/// is the one to turn on.
@ObservationIgnored private var pendingSubtitles: [(SubtitleFileRecord, Bool)] = []
/// The drawable size mpv last built its output for; see `outputResized`.
@ObservationIgnored private var outputSize: CGSize = .zero
/// Off while the app is in the background, where a refit must not bring
/// the picture back.
@ObservationIgnored private var videoEnabled = true
@ObservationIgnored private var refitTask: Task<Void, Never>?
/// Every `vid` write, in the order they were made. Each one tears down or
/// builds mpv's video output and waits on its render thread, which the
/// main thread must not; and a refit's "auto" must never land after the
/// background's "no".
@ObservationIgnored private let videoQueue = DispatchQueue(label: "mpv-video")
/// A core, or nil when libmpv would not start.
static func create() -> MpvPlayer? {
let player = MpvPlayer()
return player.mpv == nil ? nil : player
}
private init() {
layer.framebufferOnly = true
layer.backgroundColor = UIColor.black.cgColor
// For the frames between a resize and mpv's next one: a drawable of
// the old shape is letterboxed rather than stretched.
layer.contentsGravity = .resizeAspect
guard let mpv = mpv_create() else { return }
// mpv takes the layer's address as an int64, not a Swift reference.
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid)
for (name, value) in [
("config", "no"),
("vo", "gpu-next"),
("gpu-api", "vulkan"),
("gpu-context", "moltenvk"),
// The default; `load` sets it per file from the viewer's setting.
("hwdec", "videotoolbox"),
("video-rotate", "no"),
("keep-open", "no"),
("cache", "yes"),
("cache-secs", "30"),
("demuxer-readahead-secs", "30"),
("demuxer-max-bytes", "50331648"),
("subs-fallback", "yes"),
// AVAudioSession is this app's to manage, as audio focus is
// Android's.
("audio-exclusive", "no"),
] {
mpv_set_option_string(mpv, name, value)
}
guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else {
mpv_terminate_destroy(mpv)
return
}
for (id, name, format) in Self.observed {
mpv_observe_property(mpv, id, name, format)
}
// Errors mpv reports about the file itself, rather than about the
// request that opened it. Without these a failed demux is silent.
mpv_request_log_messages(mpv, "error")
self.mpv = mpv
let thread = Thread { [weak self] in Self.eventLoop(mpv) { event in self?.apply(event) } }
thread.name = "mpv-events"
thread.start()
}
private static let observed: [(UInt64, String, mpv_format)] = [
(1, "time-pos", MPV_FORMAT_DOUBLE),
(2, "duration", MPV_FORMAT_DOUBLE),
(3, "pause", MPV_FORMAT_FLAG),
(4, "volume", MPV_FORMAT_DOUBLE),
(5, "mute", MPV_FORMAT_FLAG),
(6, "video-params/dw", MPV_FORMAT_INT64),
(7, "video-params/dh", MPV_FORMAT_INT64),
(8, "paused-for-cache", MPV_FORMAT_FLAG),
(9, "cache-buffering-state", MPV_FORMAT_INT64),
(10, "demuxer-cache-time", MPV_FORMAT_DOUBLE),
(11, "track-list/count", MPV_FORMAT_INT64),
(12, "chapter-list/count", MPV_FORMAT_INT64),
(13, "seeking", MPV_FORMAT_FLAG),
]
// MARK: Commands
/// Load `pstr://<handle>` as `media`. On `false` nothing was issued, and
/// the caller still owns the handle's release.
func load(
media: String,
handle: UInt64,
start: Double,
audio: String?,
subtitle: String?,
subtitles: Bool,
hardwareDecoding: Bool
) -> Bool {
guard mpv != nil else { return false }
// Per file, before `loadfile`: a file VideoToolbox decodes wrongly is
// fixed from the next episode without restarting the core.
setString("hwdec", hardwareDecoding ? "videotoolbox" : "no")
setString("start", start > 0 ? String(format: "%.3f", start) : "none")
setString("alang", audio ?? "")
setString("slang", subtitle ?? "")
setString("sid", subtitles ? "auto" : "no")
// Retire the outgoing file's readings *before* the new one loads: mpv
// only republishes them once it has demuxed enough of the stream, and
// until then every reader would see the last episode's clock under the
// new episode's name. Volume, mute and pause are the core's, not the
// file's, and carry across.
position = start
duration = 0
endReason = nil
buffering = false
cachePercent = 0
seeking = false
cachedUntil = 0
videoWidth = 0
videoHeight = 0
tracks = []
chapters = []
problem = nil
self.media = nil
generation += 1
// Subtitles for the file being replaced will never be added.
pendingSubtitles.forEach { pstr_android_stream_release($0.0.handle) }
pendingSubtitles = []
pendingMedia = media
loading = true
if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 {
// Nothing was issued, so no START_FILE will arrive to clear this.
loading = false
return false
}
return true
}
/// Add the subtitle files beside the file of load `generation`.
///
/// Held until mpv has that file loaded, since an external track belongs to
/// the file open at the time, and released instead if another file has
/// been loaded since. `files.preferred` is turned on only when the file
/// has no subtitle of its own playing; `subtitles` false turns none on.
func addSubtitles(generation: Int, files: SubtitleFilesRecord, subtitles: Bool) {
let marked = files.files.enumerated().map { index, file in
(file, subtitles && files.preferred == UInt32(index))
}
if generation != self.generation {
marked.forEach { pstr_android_stream_release($0.0.handle) }
} else if loadedGeneration == generation {
attach(marked)
} else {
pendingSubtitles += marked
}
}
/// `sub-add` off the main thread: mpv reads the whole file before it
/// returns, through the stream protocol and so from the network.
private func attach(_ subtitles: [(SubtitleFileRecord, Bool)]) {
guard let mpv, !subtitles.isEmpty else { return }
let showing = getString("sid").map { $0 != "no" } ?? false
let core = UncheckedCore(mpv)
DispatchQueue.global(qos: .userInitiated).async {
for (file, select) in subtitles {
var args = ["sub-add", "pstr://\(file.handle)", select && !showing ? "select" : "auto", file.title]
if let language = file.language { args.append(language) }
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
pointers.append(nil)
let status = mpv_command(core.pointer, &pointers)
pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) }
// On failure mpv may never have opened it, so nothing will
// close it. Releasing one it did open and close is a no-op.
if status < 0 { pstr_android_stream_release(file.handle) }
}
}
}
/// Written through rather than left to the observer: a tap that only shows
/// its effect on mpv's next property event reads as a button that missed.
func setPaused(_ value: Bool) {
setFlag("pause", value)
paused = value
}
func seek(to seconds: Double) {
setDouble("time-pos", max(0, seconds))
seeking = true
}
func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) }
func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) }
func setMuted(_ value: Bool) { setFlag("mute", value) }
/// A track, or none: `nil` turns subtitles off.
func select(type: String, track: MpvTrack?) {
setString(type == "audio" ? "aid" : "sid", track.map { String($0.id) } ?? "no")
// mpv makes the switch on its own thread and lists it a moment later,
// so a track list read back now still ticks the old track. The tick
// moves here, and a read shortly after confirms it.
tracks = tracks.map { listed in
var listed = listed
if listed.type == type { listed.selected = listed.id == track?.id }
return listed
}
Task { [weak self] in
try? await Task.sleep(for: .milliseconds(300))
self?.refreshTracks()
}
}
/// Showing no picture while backgrounded keeps the audio playing: iOS
/// refuses GPU work from a background app, and would end the process for it.
func setVideoEnabled(_ enabled: Bool) {
videoEnabled = enabled
setVideo([enabled ? "auto" : "no"])
}
/// The layer's drawable changed size: a rotation, a Split View resize.
///
/// MPVKit's MoltenVK context reads `drawableSize` only when mpv builds its
/// output and never hears of a resize after that (mpvkit/MPVKit#3), so mpv
/// went on drawing a frame of the old size into the corner of the new
/// drawable. Turning the picture off and on builds the output again at the
/// new size — the same round trip the background makes, at the cost of a
/// refresh seek. Once the size has settled, so a Split View drag rebuilds
/// it once rather than per step.
func outputResized() {
refitTask?.cancel()
refitTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, let self else { return }
let size = layer.drawableSize
// Only an output that exists and is the wrong size: nothing to fit
// while a file loads, and the background's "no" stays.
guard videoEnabled, videoWidth > 0, outputSize != .zero, size != outputSize,
size.width > 1, size.height > 1 else { return }
outputSize = size
setVideo(["no", "auto"])
}
}
private func setVideo(_ values: [String]) {
guard let mpv else { return }
let core = UncheckedCore(mpv)
videoQueue.async {
for value in values { mpv_set_property_string(core.pointer, "vid", value) }
}
}
/// Stop the open file. The stream behind it is released by mpv closing it,
/// which this causes; releasing it from here would race that close.
func stop() {
_ = command(["stop"])
media = nil
pendingMedia = nil
chapters = []
tracks = []
}
// MARK: Events
private enum Event {
case double(UInt64, Double)
case flag(UInt64, Bool)
case int(UInt64, Int64)
case unavailable(UInt64)
case started
case loaded
case ended(Int64)
case restarted
case reconfigured
case log(prefix: String, text: String)
}
/// Runs until the core shuts down, then destroys it. Nothing else calls
/// `mpv_terminate_destroy`, so no main-thread call can race it.
private nonisolated static func eventLoop(_ mpv: OpaquePointer, _ deliver: @escaping @MainActor (Event) -> Void) {
while true {
guard let event = mpv_wait_event(mpv, -1)?.pointee else { continue }
var decoded: Event?
switch event.event_id {
case MPV_EVENT_SHUTDOWN:
mpv_terminate_destroy(mpv)
return
case MPV_EVENT_PROPERTY_CHANGE:
guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee else { break }
guard let data = property.data else {
decoded = .unavailable(event.reply_userdata)
break
}
switch property.format {
case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self))
case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0)
case MPV_FORMAT_INT64: decoded = .int(event.reply_userdata, data.load(as: Int64.self))
default: decoded = .unavailable(event.reply_userdata)
}
case MPV_EVENT_START_FILE:
decoded = .started
case MPV_EVENT_FILE_LOADED:
decoded = .loaded
case MPV_EVENT_PLAYBACK_RESTART:
decoded = .restarted
case MPV_EVENT_VIDEO_RECONFIG:
decoded = .reconfigured
case MPV_EVENT_END_FILE:
if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee {
decoded = .ended(Int64(end.reason.rawValue))
}
case MPV_EVENT_LOG_MESSAGE:
if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee,
let text = message.text {
decoded = .log(
prefix: message.prefix.map { String(cString: $0) } ?? "",
text: String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)
)
}
default:
break
}
if let decoded {
DispatchQueue.main.async { deliver(decoded) }
}
}
}
private func apply(_ event: Event) {
switch event {
case let .double(id, value):
if loading { return }
switch id {
case 1:
position = value
onTick?()
case 2:
duration = value
onStateChanged?()
case 4: volume = value
case 10: cachedUntil = value
default: break
}
case let .flag(id, value):
switch id {
case 3:
paused = value
onStateChanged?()
case 5: muted = value
case 8:
// Said, so the lock screen's clock stops through a stall.
if !loading, buffering != value {
buffering = value
onStateChanged?()
}
case 13: if !loading { seeking = value }
default: break
}
case let .int(id, value):
if loading { return }
switch id {
case 6: videoWidth = Double(value)
case 7: videoHeight = Double(value)
case 9: cachePercent = Double(value)
case 11: refreshTracks()
case 12: refreshChapters()
default: break
}
case let .unavailable(id):
if loading { return }
if id == 6 || id == 7 { videoWidth = 0; videoHeight = 0 }
case .started:
// From here on, what mpv reports is the file just loaded.
if loading {
loading = false
media = pendingMedia
}
case .loaded:
refreshTracks()
refreshChapters()
// Mid-load, this is the outgoing file finishing its load.
if !loading {
loadedGeneration = generation
let ready = pendingSubtitles
pendingSubtitles = []
attach(ready)
onLoaded?()
}
case .restarted:
seeking = false
case .reconfigured:
// mpv has just read the drawable to size its output by.
outputSize = layer.drawableSize
case let .ended(reason):
// The outgoing file's END_FILE arrives after the next load began.
if loading { return }
let ended: EndReason = switch reason {
case Int64(MPV_END_FILE_REASON_EOF.rawValue): .eof
case Int64(MPV_END_FILE_REASON_STOP.rawValue): .stopped
case Int64(MPV_END_FILE_REASON_QUIT.rawValue): .quit
case Int64(MPV_END_FILE_REASON_ERROR.rawValue): .failed
default: .other
}
endReason = ended
if ended == .failed { problem = problem ?? "This episode could not be played." }
if let media { onEnded?(ended, media) }
case let .log(prefix, text):
// The outgoing file's complaints are not the new one's.
if loading { return }
// The first complaint per file, and not ffmpeg's: its decoders log
// recoverable glitches at error level all through a healthy file.
if text.isEmpty || prefix.hasPrefix("ffmpeg") || problem != nil { return }
problem = text
}
}
private func refreshTracks() {
let count = getInt("track-list/count")
tracks = (0 ..< max(0, count)).map { index in
let base = "track-list/\(index)/"
return MpvTrack(
id: getInt(base + "id"),
type: getString(base + "type") ?? "",
language: getString(base + "lang") ?? "",
title: getString(base + "title") ?? "",
selected: getString(base + "selected") == "yes",
isDefault: getString(base + "default") == "yes",
forced: getString(base + "forced") == "yes"
)
}
}
private func refreshChapters() {
let count = getInt("chapter-list/count")
chapters = (0 ..< max(0, count)).map { index in
let base = "chapter-list/\(index)/"
return ChapterRecord(index: index, title: getString(base + "title"), start: getDouble(base + "time"))
}
}
// MARK: Property plumbing
private func command(_ args: [String]) -> Int32 {
guard let mpv else { return -1 }
var pointers = args.map { UnsafePointer<CChar>(strdup($0)) }
pointers.append(nil)
defer { pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) } }
return mpv_command(mpv, &pointers)
}
private func setString(_ name: String, _ value: String) {
guard let mpv else { return }
mpv_set_property_string(mpv, name, value)
}
private func setDouble(_ name: String, _ value: Double) {
guard let mpv else { return }
var value = value
mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
}
private func setFlag(_ name: String, _ value: Bool) {
guard let mpv else { return }
var flag: Int32 = value ? 1 : 0
mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag)
}
private func getInt(_ name: String) -> Int64 {
guard let mpv else { return 0 }
var value: Int64 = 0
mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value)
return value
}
private func getDouble(_ name: String) -> Double {
guard let mpv else { return 0 }
var value = 0.0
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value)
return value
}
private func getString(_ name: String) -> String? {
guard let mpv, let raw = mpv_get_property_string(mpv, name) else { return nil }
defer { mpv_free(raw) }
return String(cString: raw)
}
}
/// mpv's handle, for a command issued off the main thread. libmpv's client API
/// is thread-safe, and the core is only destroyed when it shuts down, which
/// nothing in the app asks it to.
private struct UncheckedCore: @unchecked Sendable {
let pointer: OpaquePointer
init(_ pointer: OpaquePointer) { self.pointer = pointer }
}