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:
Guido
2026-09-11 17:24:17 +02:00
co-authored by Claude Opus 5
parent d0a84f35de
commit 55bcfe2fbf
13 changed files with 1512 additions and 53 deletions
+7
View File
@@ -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
+337
View File
@@ -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"
+82
View File
@@ -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
)
}
}
+226 -25
View File
@@ -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)
}
}
+93 -24
View File
@@ -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
+80
View File
@@ -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)
}
}
+37
View File
@@ -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"
}
}
}