Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync
Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
@main
|
||||
@@ -10,6 +11,10 @@ struct FlixyApp: App {
|
||||
@State private var theme = FlixyTheme()
|
||||
@State private var session: FlixySession
|
||||
@State private var playback = PlaybackCoordinator()
|
||||
@State private var playbackSettings = PlaybackSettings()
|
||||
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
|
||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||
@State private var network = NetworkConditions()
|
||||
|
||||
init() {
|
||||
let identity = PlexClientIdentity.current(
|
||||
@@ -27,6 +32,8 @@ struct FlixyApp: App {
|
||||
RootView()
|
||||
.environment(session)
|
||||
.environment(playback)
|
||||
.environment(playbackSettings)
|
||||
.environment(network)
|
||||
.environment(theme)
|
||||
.flixyAccent(theme)
|
||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
||||
|
||||
@@ -1839,6 +1839,343 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.tracks": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ton und Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio and subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.audioTracks": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tonspur"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff": {
|
||||
"comment": "Eintrag, der die Untertitel abschaltet",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Aus"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.skipIntro": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Intro überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip intro"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.skipCredits": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Abspann überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip credits"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.nextEpisode": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nächste Folge"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Next episode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.transcodeRefused": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Der Server hat die Wiedergabe abgelehnt."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The server refused playback."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.quality": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Qualität"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Quality"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.qualityWifi": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Im WLAN"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "On Wi-Fi"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.qualityCellular": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Über Mobilfunk"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "On cellular"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.qualityExplanation": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playback": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wiedergabe"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Playback"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.autoSkipIntro": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Intro automatisch überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip intros automatically"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.autoSkipCredits": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Abspann automatisch überspringen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Skip credits automatically"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.autoPlayNext": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Nächste Folge automatisch"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Play next episode automatically"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.original": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Original"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Original"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.high": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Hoch (12 Mbit/s)"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "High (12 Mbps)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.medium": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Mittel (8 Mbit/s)"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Medium (8 Mbps)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.low": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Niedrig (4 Mbit/s)"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Low (4 Mbps)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.minimal": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Sparsam (2 Mbit/s)"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Data saver (2 Mbps)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.1"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FlixyPlayback
|
||||
|
||||
/// Was der Nutzer über die Wiedergabe eingestellt hat.
|
||||
///
|
||||
/// Getrennt nach WLAN und Mobilfunk, weil sich die Anforderungen dort
|
||||
/// grundlegend unterscheiden: im Heimnetz ist die Originaldatei das Beste,
|
||||
/// unterwegs zählt das Datenvolumen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlaybackSettings {
|
||||
/// Die Stufen, die zur Auswahl stehen.
|
||||
///
|
||||
/// Bewusst wenige und in Schritten, die man spürt. Eine freie Eingabe in
|
||||
/// Kilobit hilft niemandem bei der Frage „reicht das für heute Abend".
|
||||
enum Quality: Int, CaseIterable, Identifiable, Sendable {
|
||||
case original = 0
|
||||
case high = 12_000
|
||||
case medium = 8_000
|
||||
case low = 4_000
|
||||
case minimal = 2_000
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// `nil` heißt: keine Grenze, also die Originaldatei.
|
||||
var bitrateLimit: Int? { self == .original ? nil : rawValue }
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let wifiQuality = "flixy.playback.quality.wifi"
|
||||
static let cellularQuality = "flixy.playback.quality.cellular"
|
||||
static let autoSkipIntro = "flixy.playback.autoSkipIntro"
|
||||
static let autoSkipCredits = "flixy.playback.autoSkipCredits"
|
||||
static let autoPlayNext = "flixy.playback.autoPlayNext"
|
||||
static let preferredAudioLanguage = "flixy.playback.audioLanguage"
|
||||
static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage"
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
|
||||
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
|
||||
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
|
||||
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
|
||||
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
|
||||
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
|
||||
var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } }
|
||||
var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } }
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
|
||||
// Voreinstellung: im WLAN das Original, unterwegs 4 Mbit. Das ist die
|
||||
// Aufteilung, die im Alltag am seltensten stört — zuhause die beste
|
||||
// Qualität, unterwegs ein Wert, der auch bei mäßigem Empfang trägt.
|
||||
self.wifiQuality = Quality(rawValue: defaults.object(forKey: Key.wifiQuality) as? Int ?? 0) ?? .original
|
||||
self.cellularQuality = Quality(rawValue: defaults.object(forKey: Key.cellularQuality) as? Int ?? 4_000) ?? .low
|
||||
|
||||
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
|
||||
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
|
||||
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
|
||||
self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? ""
|
||||
self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? ""
|
||||
}
|
||||
|
||||
/// Die Richtlinie für die aktuelle Lage.
|
||||
///
|
||||
/// Das ist die Stelle, an der Phase 2 greift: `allowsTranscoding` steht
|
||||
/// jetzt auf `true`. Am Decider selbst musste dafür nichts geändert werden.
|
||||
func policy(for conditions: NetworkConditions) -> PlaybackPolicy {
|
||||
let quality = conditions.isExpensive || conditions.isConstrained
|
||||
? cellularQuality
|
||||
: wifiQuality
|
||||
|
||||
return PlaybackPolicy(
|
||||
allowsTranscoding: true,
|
||||
allowsFallbackEngine: true,
|
||||
maximumBitrate: quality.bitrateLimit
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import PlexKit
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die
|
||||
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
|
||||
/// an der die Bildfläche gebaut wird.
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
|
||||
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
|
||||
/// Bildfläche gebaut wird.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlayerModel {
|
||||
@@ -29,16 +29,51 @@ final class PlayerModel {
|
||||
private(set) var duration: Duration?
|
||||
private(set) var isPlaying = false
|
||||
|
||||
private(set) var audioTracks: [MediaTrack] = []
|
||||
private(set) var subtitleTracks: [MediaTrack] = []
|
||||
private(set) var selectedAudioTrack: MediaTrack?
|
||||
private(set) var selectedSubtitleTrack: MediaTrack?
|
||||
|
||||
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
|
||||
/// Überspringen-Knopf.
|
||||
private(set) var activeMarker: PlexMarker?
|
||||
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
||||
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
||||
private(set) var nextEpisode: PlexItem?
|
||||
private(set) var hasReachedEnd = false
|
||||
|
||||
private let request: PlaybackRequest
|
||||
private let server: PlexServer?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private let policy: PlaybackPolicy
|
||||
private let settings: PlaybackSettings
|
||||
private let isLocalConnection: Bool
|
||||
|
||||
init(request: PlaybackRequest, server: PlexServer?) {
|
||||
private var item: PlexItem?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private var reportTask: Task<Void, Never>?
|
||||
private var transcodeSession: String?
|
||||
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
|
||||
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
|
||||
/// lassen — und man käme nie zurück.
|
||||
private var skippedMarkers: Set<Int> = []
|
||||
|
||||
init(
|
||||
request: PlaybackRequest,
|
||||
server: PlexServer?,
|
||||
policy: PlaybackPolicy,
|
||||
settings: PlaybackSettings,
|
||||
isLocalConnection: Bool
|
||||
) {
|
||||
self.request = request
|
||||
self.server = server
|
||||
self.policy = policy
|
||||
self.settings = settings
|
||||
self.isLocalConnection = isLocalConnection
|
||||
self.title = request.title
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
func start() async {
|
||||
guard let server else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
@@ -53,39 +88,76 @@ final class PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
self.item = item
|
||||
title = item.rowTitle
|
||||
subtitle = playerSubtitle(for: item)
|
||||
|
||||
guard let media = version(of: item),
|
||||
let part = media.parts.first,
|
||||
let url = server.directPlayURL(part: part)
|
||||
else {
|
||||
guard let media = version(of: item), let part = media.parts.first else {
|
||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
|
||||
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
self.decision = decision
|
||||
|
||||
guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else {
|
||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||
return
|
||||
}
|
||||
|
||||
let engine = makeEngine(for: decision)
|
||||
self.engine = engine
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
try await engine.load(PlaybackSource(
|
||||
url: url,
|
||||
origin: .remote,
|
||||
startTime: request.startTime
|
||||
))
|
||||
try await engine.load(source)
|
||||
engine.play()
|
||||
phase = .playing
|
||||
startReporting()
|
||||
preloadNextEpisode()
|
||||
} catch {
|
||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom.
|
||||
private func source(
|
||||
for decision: PlaybackDecision,
|
||||
item: PlexItem,
|
||||
media: PlexMedia,
|
||||
part: PlexPart,
|
||||
server: PlexServer
|
||||
) async -> PlaybackSource? {
|
||||
switch decision.method {
|
||||
case .directPlay:
|
||||
guard let url = server.directPlayURL(part: part) else { return nil }
|
||||
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
|
||||
|
||||
case .transcode(let maximumBitrate):
|
||||
let transcode = TranscodeRequest(
|
||||
ratingKey: item.ratingKey,
|
||||
mediaIndex: request.mediaIndex,
|
||||
maximumBitrate: maximumBitrate,
|
||||
isLocal: isLocalConnection
|
||||
)
|
||||
transcodeSession = transcode.sessionIdentifier
|
||||
|
||||
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier
|
||||
// schon nein, steht der Grund im Klartext da — statt eines
|
||||
// schwarzen Bildes nach zwanzig Sekunden Warten.
|
||||
if let decision = try? await server.transcodeDecision(for: transcode),
|
||||
!decision.isAllowed {
|
||||
phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused"))
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let url = server.transcodeURL(for: transcode) else { return nil }
|
||||
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let engine else { return }
|
||||
if isPlaying { engine.pause() } else { engine.play() }
|
||||
@@ -99,16 +171,114 @@ final class PlayerModel {
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
func skip(by offset: Duration) {
|
||||
guard let duration else { return }
|
||||
let target = currentTime + offset
|
||||
seek(to: max(.zero, min(target, duration)))
|
||||
seek(to: max(.zero, min(currentTime + offset, duration)))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
/// Springt ans Ende der laufenden Marke.
|
||||
func skipActiveMarker() {
|
||||
guard let marker = activeMarker else { return }
|
||||
skippedMarkers.insert(marker.id)
|
||||
seek(to: .milliseconds(marker.endTimeOffset))
|
||||
}
|
||||
|
||||
func selectAudioTrack(_ track: MediaTrack) {
|
||||
engine?.selectAudioTrack(track)
|
||||
selectedAudioTrack = track
|
||||
}
|
||||
|
||||
/// `nil` schaltet die Untertitel ab.
|
||||
func selectSubtitleTrack(_ track: MediaTrack?) {
|
||||
engine?.selectSubtitleTrack(track)
|
||||
selectedSubtitleTrack = track
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
eventTask?.cancel()
|
||||
reportTask?.cancel()
|
||||
|
||||
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
|
||||
// Server aus „wird gerade abgespielt" heraus.
|
||||
await report(state: .stopped)
|
||||
|
||||
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
|
||||
// minutenlang weiter, obwohl niemand mehr zusieht.
|
||||
if let transcodeSession, let server {
|
||||
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
||||
}
|
||||
|
||||
engine?.teardown()
|
||||
engine = nil
|
||||
}
|
||||
|
||||
// MARK: - Fortschritt melden
|
||||
|
||||
private func startReporting() {
|
||||
reportTask?.cancel()
|
||||
reportTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
|
||||
// selbst. Häufiger brächte nichts, seltener verlöre beim
|
||||
// Abwürgen der App zu viel.
|
||||
try? await Task.sleep(for: .seconds(10))
|
||||
guard let self, !Task.isCancelled else { return }
|
||||
await report(state: isPlaying ? .playing : .paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func report(state: PlaybackReportState) async {
|
||||
guard let server, let item, let duration, currentTime > .zero else { return }
|
||||
await server.reportProgress(
|
||||
ratingKey: item.ratingKey,
|
||||
state: state,
|
||||
time: currentTime,
|
||||
duration: duration
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Marken und nächste Folge
|
||||
|
||||
private func updateActiveMarker() {
|
||||
guard let item else { return }
|
||||
let milliseconds = currentTime.milliseconds
|
||||
|
||||
activeMarker = item.markers.first { marker in
|
||||
guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else {
|
||||
return false
|
||||
}
|
||||
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
|
||||
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
|
||||
// „Film beenden".
|
||||
if marker.isCredits && item.kind != .episode { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
|
||||
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|
||||
|| (marker.isCredits && settings.autoSkipCredits)
|
||||
if shouldSkip { skipActiveMarker() }
|
||||
}
|
||||
|
||||
private func preloadNextEpisode() {
|
||||
guard let item, item.kind == .episode, let server else { return }
|
||||
Task { [weak self] in
|
||||
let next = try? await server.nextEpisode(after: item)
|
||||
self?.nextEpisode = next
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
|
||||
/// Koordinator weiter.
|
||||
func nextEpisodeRequest() -> PlaybackRequest? {
|
||||
guard let nextEpisode else { return nil }
|
||||
return PlaybackRequest(
|
||||
ratingKey: nextEpisode.ratingKey,
|
||||
title: nextEpisode.playbackTitle,
|
||||
mediaIndex: 0,
|
||||
startTime: .zero
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Innereien
|
||||
|
||||
private func version(of item: PlexItem) -> PlexMedia? {
|
||||
@@ -119,7 +289,8 @@ final class PlayerModel {
|
||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||
switch decision.engine {
|
||||
case .vlc: VLCPlayerEngine()
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
|
||||
// genau darin wechselt AVPlayer die Bitrate von selbst.
|
||||
case .avPlayer, .none: AVPlayerEngine()
|
||||
}
|
||||
}
|
||||
@@ -137,19 +308,36 @@ final class PlayerModel {
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
currentTime = time
|
||||
updateActiveMarker()
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
case .tracksResolved:
|
||||
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
|
||||
// schon — die Ansicht bietet sie nur noch nicht an.
|
||||
break
|
||||
hasReachedEnd = true
|
||||
case .tracksResolved(let audio, let subtitle):
|
||||
audioTracks = audio
|
||||
subtitleTracks = subtitle
|
||||
applyPreferredLanguages()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wählt die bevorzugten Sprachen, sofern es sie gibt.
|
||||
///
|
||||
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
|
||||
/// eine später gemeldete Spurliste das nicht zurücksetzen.
|
||||
private func applyPreferredLanguages() {
|
||||
if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty,
|
||||
let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) {
|
||||
selectAudioTrack(match)
|
||||
}
|
||||
if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty,
|
||||
let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) {
|
||||
selectSubtitleTrack(match)
|
||||
}
|
||||
}
|
||||
|
||||
private func playerSubtitle(for item: PlexItem) -> String? {
|
||||
guard item.kind == .episode else { return nil }
|
||||
if let season = item.parentIndex, let episode = item.index {
|
||||
@@ -158,3 +346,16 @@ final class PlayerModel {
|
||||
return item.title
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaTrack {
|
||||
/// Ob die Spur zu einem Sprachcode passt.
|
||||
///
|
||||
/// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal
|
||||
/// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger
|
||||
/// Vergleich fände die Spur in der Hälfte der Fälle nicht.
|
||||
func matches(language code: String) -> Bool {
|
||||
guard let languageCode, !code.isEmpty else { return false }
|
||||
let normalized = languageCode.lowercased().prefix(2)
|
||||
return normalized == code.lowercased().prefix(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ struct PlayerView: View {
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var model: PlayerModel?
|
||||
@State private var showsControls = true
|
||||
@State private var showsTrackPicker = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var scrubbing: Double?
|
||||
|
||||
@@ -33,15 +36,18 @@ struct PlayerView: View {
|
||||
|
||||
case .playing:
|
||||
if showsControls {
|
||||
controls(for: model)
|
||||
.transition(.opacity)
|
||||
controls(for: model).transition(.opacity)
|
||||
}
|
||||
// Überspringen und „Nächste Folge" liegen außerhalb der
|
||||
// Bedienelemente: sie sollen auch dann erscheinen, wenn
|
||||
// die ausgeblendet sind — sonst müsste man erst antippen,
|
||||
// um das Intro überspringen zu können.
|
||||
overlayActions(for: model)
|
||||
}
|
||||
|
||||
// Die Kopfzeile mit dem Schließen-Knopf liegt über allem und
|
||||
// erscheint auch, solange noch geladen wird. Ohne sie sitzt
|
||||
// man bei einer langsamen Verbindung fest: kein Zurück, kein
|
||||
// Abbrechen, nur ein Ladekreis.
|
||||
// Die Kopfzeile liegt über allem und erscheint auch während
|
||||
// des Ladens. Ohne sie sitzt man bei langsamer Verbindung
|
||||
// fest: kein Zurück, kein Abbrechen, nur ein Ladekreis.
|
||||
if showsControls || !isPlayingPhase(model) {
|
||||
VStack {
|
||||
topBar(for: model)
|
||||
@@ -51,21 +57,33 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.statusBarHidden(showsControls == false)
|
||||
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
|
||||
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
|
||||
.statusBarHidden(!showsControls)
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
if let model { TrackPickerSheet(model: model) }
|
||||
}
|
||||
.task {
|
||||
let model = PlayerModel(request: request, server: session.server)
|
||||
let model = PlayerModel(
|
||||
request: request,
|
||||
server: session.server,
|
||||
policy: settings.policy(for: network),
|
||||
settings: settings,
|
||||
isLocalConnection: session.server?.connection.local ?? false
|
||||
)
|
||||
self.model = model
|
||||
await model.start()
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.onDisappear {
|
||||
hideTask?.cancel()
|
||||
model?.stop()
|
||||
// Losgelöst von der Ansicht: das Anhalten des Transcoders und die
|
||||
// Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht
|
||||
// schon verschwunden ist.
|
||||
if let model {
|
||||
Task { await model.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,14 +101,14 @@ struct PlayerView: View {
|
||||
.padding(FlixySpacing.loose)
|
||||
.background {
|
||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
|
||||
// wirkt.
|
||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||||
// verfälscht wirkt.
|
||||
Color.black.opacity(0.35).ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private func topBar(for model: PlayerModel) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||
Button {
|
||||
playback.dismiss()
|
||||
} label: {
|
||||
@@ -114,6 +132,18 @@ struct PlayerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !model.audioTracks.isEmpty || !model.subtitleTracks.isEmpty {
|
||||
Button {
|
||||
showsTrackPicker = true
|
||||
hideTask?.cancel()
|
||||
} label: {
|
||||
Image(systemName: "captions.bubble")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.tracks"))
|
||||
}
|
||||
|
||||
if let decision = model.decision {
|
||||
PlaybackMethodBadge(decision: decision)
|
||||
}
|
||||
@@ -173,10 +203,10 @@ struct PlayerView: View {
|
||||
.tint(accent)
|
||||
|
||||
HStack {
|
||||
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
|
||||
Text(displayTime(for: model).timecode)
|
||||
Spacer()
|
||||
if let duration = model.duration {
|
||||
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
|
||||
Text("−" + (duration - displayTime(for: model)).timecode)
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
@@ -184,11 +214,53 @@ struct PlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Während geladen wird.
|
||||
///
|
||||
/// Zeigt den Titel mit an, damit erkennbar bleibt, worauf gewartet wird —
|
||||
/// und weil ein nackter Ladekreis nach zehn Sekunden wie ein Absturz
|
||||
/// aussieht.
|
||||
private func displayTime(for model: PlayerModel) -> Duration {
|
||||
scrubbing.map(Duration.seconds) ?? model.currentTime
|
||||
}
|
||||
|
||||
/// Überspringen-Knopf und „Nächste Folge".
|
||||
@ViewBuilder
|
||||
private func overlayActions(for model: PlayerModel) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
|
||||
Button {
|
||||
if let next = model.nextEpisodeRequest() {
|
||||
playback.request = next
|
||||
}
|
||||
} label: {
|
||||
Label("player.nextEpisode", systemImage: "forward.end.fill")
|
||||
.font(.flixyCardTitle)
|
||||
.padding(.horizontal, FlixySpacing.loose)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
} else if let marker = model.activeMarker {
|
||||
Button {
|
||||
model.skipActiveMarker()
|
||||
} label: {
|
||||
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
|
||||
.font(.flixyCardTitle)
|
||||
.padding(.horizontal, FlixySpacing.loose)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.white)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.section)
|
||||
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
|
||||
// die Bedienelemente eingeblendet sind.
|
||||
.padding(.bottom, showsControls ? 110 : FlixySpacing.section)
|
||||
}
|
||||
.animation(.snappy, value: model.activeMarker?.id)
|
||||
.animation(.snappy, value: model.hasReachedEnd)
|
||||
}
|
||||
|
||||
private func preparing(for model: PlayerModel) -> some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
ProgressView().tint(.white)
|
||||
@@ -245,9 +317,6 @@ struct PlayerView: View {
|
||||
}
|
||||
|
||||
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
|
||||
///
|
||||
/// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob
|
||||
/// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag.
|
||||
struct PlaybackMethodBadge: View {
|
||||
let decision: PlaybackDecision
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
|
||||
/// Auswahl von Ton- und Untertitelspur.
|
||||
struct TrackPickerSheet: View {
|
||||
let model: PlayerModel
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !model.audioTracks.isEmpty {
|
||||
Section("player.audioTracks") {
|
||||
ForEach(model.audioTracks) { track in
|
||||
row(
|
||||
title: track.displayName,
|
||||
isSelected: model.selectedAudioTrack == track
|
||||
) {
|
||||
model.selectAudioTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !model.subtitleTracks.isEmpty {
|
||||
Section("player.subtitleTracks") {
|
||||
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht
|
||||
// als eigener Schalter daneben: es ist eine Auswahl
|
||||
// unter mehreren, keine getrennte Eigenschaft.
|
||||
row(
|
||||
title: String(localized: "player.subtitlesOff"),
|
||||
isSelected: model.selectedSubtitleTrack == nil
|
||||
) {
|
||||
model.selectSubtitleTrack(nil)
|
||||
}
|
||||
|
||||
ForEach(model.subtitleTracks) { track in
|
||||
row(
|
||||
title: track.displayName,
|
||||
isSelected: model.selectedSubtitleTrack == track
|
||||
) {
|
||||
model.selectSubtitleTrack(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("player.tracks"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Die Einstellungen.
|
||||
@@ -9,6 +10,7 @@ import PlexKit
|
||||
/// es das Geregelte auch gibt.
|
||||
struct SettingsView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
@@ -29,6 +31,29 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.qualityWifi", selection: Bindable(settings).wifiQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
Picker("settings.qualityCellular", selection: Bindable(settings).cellularQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.quality")
|
||||
} footer: {
|
||||
Text("settings.qualityExplanation")
|
||||
}
|
||||
|
||||
Section("settings.playback") {
|
||||
Toggle("settings.autoSkipIntro", isOn: Bindable(settings).autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: Bindable(settings).autoSkipCredits)
|
||||
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
}
|
||||
@@ -63,3 +88,15 @@ struct SettingsView: View {
|
||||
return String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybackSettings.Quality {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .high: "quality.high"
|
||||
case .medium: "quality.medium"
|
||||
case .low: "quality.low"
|
||||
case .minimal: "quality.minimal"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user