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>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user