Files
Flaro/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift
T
GuidoandClaude Opus 5 9a69df4635 Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht:
- plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund
- Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor
  Vermittlung), mit Abgleich der Server-Kennung gegen veraltete
  plex.direct-Adressen
- PlexServer als einzige Stelle, an der die App Endpunkte kennt:
  Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten,
  Staffeln/Folgen, Suche

Die Oberfläche:
- Startseite mit Aufmacher und den Reihen vom Server
- Mediathek als Raster oder Liste, mit Filter- und Sortierblatt
- Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung,
  Fassungsauswahl und aufklappbaren technischen Angaben
- Suche mit gemerkten Anfragen
- Vollbildplayer mit eigenem Chrome

Der Player:
- AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll
- VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test`
  in FlixyKit nicht das 920-MB-Binärpaket mitzieht

Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf,
dass `strong=true` einen dreißigstelligen Code liefert, der sich bei
plex.tv/link nicht eintippen lässt — jetzt der kurze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:46:28 +02:00

262 lines
8.9 KiB
Swift

import AVFoundation
import Foundation
/// Wiedergabe über den Systemplayer.
///
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
/// nachgebaut werden müsste.
@MainActor
public final class AVPlayerEngine: VideoPlayerEngine {
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
public let player = AVPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
let seconds = player.currentTime().seconds
guard seconds.isFinite, seconds >= 0 else { return .zero }
return .seconds(seconds)
}
public let supportsPictureInPicture = true
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any?
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
public init() {
AudioSession.activate()
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
teardownObservers()
state = .loading
let asset = AVURLAsset(url: source.url)
let item = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: item)
observe(item: item)
observePlayer()
do {
let assetDuration = try await asset.load(.duration)
if assetDuration.isNumeric {
let value = Duration.seconds(assetDuration.seconds)
duration = value
continuation?.yield(.durationResolved(value))
}
try await loadTracks(from: asset)
} catch {
let failure = PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: String(describing: error)
)
state = .failed(failure)
throw failure
}
if source.startTime > .zero {
await seek(to: source.startTime)
}
}
public func play() {
player.play()
}
public func pause() {
player.pause()
}
public func seek(to time: Duration) async {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
// Sekunden daneben.
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
continuation?.yield(.timeChanged(time))
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, in: .audible)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, in: .legible)
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
continuation?.finish()
continuation = nil
state = .idle
AudioSession.deactivate()
}
// MARK: - Spurwahl
private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) {
guard let item = player.currentItem,
let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic)
else { return }
guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else {
// `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton
// sollte es nicht vorkommen.
item.select(nil, in: group)
return
}
item.select(group.options[index], in: group)
}
private func loadTracks(from asset: AVURLAsset) async throws {
func tracks(
for characteristic: AVMediaCharacteristic,
kind: MediaTrack.Kind
) async throws -> [MediaTrack] {
guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] }
return group.options.enumerated().map { index, option in
MediaTrack(
id: "av-\(kind)-\(index)",
kind: kind,
displayName: option.displayName,
languageCode: option.extendedLanguageTag,
engineIdentifier: index,
isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)
)
}
}
audioTracks = try await tracks(for: .audible, kind: .audio)
subtitleTracks = try await tracks(for: .legible, kind: .subtitle)
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
// MARK: - Beobachtung
private func observePlayer() {
observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
MainActor.assumeIsolated {
guard let self else { return }
switch player.timeControlStatus {
case .playing: self.state = .playing
case .paused: if self.state != .ended { self.state = .paused }
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
@unknown default: break
}
}
}
)
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] time in
MainActor.assumeIsolated {
guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
}
}
}
private func observe(item: AVPlayerItem) {
observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in
MainActor.assumeIsolated {
guard let self, item.status == .failed else { return }
self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) }
))
}
}
)
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.state = .ended
self.continuation?.yield(.reachedEnd)
}
}
}
private func teardownObservers() {
observations.forEach { $0.invalidate() }
observations.removeAll()
if let timeObserver {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
}
}
extension Duration {
/// Die Dauer als Gleitkommazahl in Sekunden — AVFoundation rechnet darin.
public var seconds: Double {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
}
/// Die Tonsitzung des Systems.
///
/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
enum AudioSession {
static func activate() {
#if os(iOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
static func deactivate() {
#if os(iOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}