Player: vier Knöpfe, die vorher fehlten oder nichts sagten
**Eine Taste zum Vergrößern** unten rechts auf der Kachel. Das Antippen der Fläche vergrößert weiterhin, aber auf derselben Fläche liegen schon das Ziehen und das Aufziehen — und was dabei danebengeht, sieht aus wie „reagiert nicht". Ein Knopf geht nicht daneben. **Ton und Untertitel getrennt.** Es sind zwei verschiedene Fragen: die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im Film. In einem gemeinsamen Blatt scrollte man jedes Mal an der anderen Liste vorbei. Das Untertitel-Symbol ist gefüllt, solange welche laufen — so sieht man es, ohne das Blatt zu öffnen. Und eine Auswahl schließt das Blatt jetzt selbst. **AirPlay.** Nicht nachgebaut, sondern die Systemstelle: sie kennt Geräte, die eine App nie aufzählen könnte. Der Knopf erscheint nur beim Systemplayer — VLC zeichnet selbst und kann kein AirPlay, und ein Knopf, der dort ins Leere führte, wäre schlimmer als keiner. **Die Qualität steht als Text da**, nicht als drei Schieberegler: „1080p", „720p", „Auto". Bei „automatisch" steht dort, was gerade tatsächlich ankommt — das weiß nur der Player, und ihn zu fragen ist ehrlicher, als „Auto" hinzuschreiben und die Antwort schuldig zu bleiben. Dazu die Einstellungen mit farbigen Symbolen und einer Kopfkarte je Bereich. Das ist nicht nur Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das Auge nichts wieder, und „das mit dem blauen Zahnrad" ist eine brauchbare Beschreibung. Die Farben sind fest und ausdrücklich **nicht** die Akzentfarbe — trüge alles dieselbe, unterschiede sie nichts mehr. Und der Aufmacher läuft jetzt unter der Seitenleiste weiter (`backgroundExtensionEffect`): die Ränder werden gespiegelt und weich fortgeschrieben, während Titel und Knöpfe stehen bleiben. Vorher endete das Bild an der Kante der Leiste, und beim Weiterwandern schob sich das nächste an einer harten Linie ins Bild. 263 Tests grün. Player-Knöpfe und Einstellungen angesehen; den Aufmacher hinter der Leiste konnte ich an den einfarbigen Flächen der Attrappe nicht sicher beurteilen — der Simulator ist dabei erneut abgestürzt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
95723588c2
commit
21c5a8b46b
@@ -1831,6 +1831,7 @@
|
||||
}
|
||||
},
|
||||
"home.combineContinue": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1896,6 +1897,7 @@
|
||||
}
|
||||
},
|
||||
"home.heroCount": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1928,6 +1930,7 @@
|
||||
}
|
||||
},
|
||||
"home.heroSource": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1944,6 +1947,7 @@
|
||||
}
|
||||
},
|
||||
"home.heroSource.continueFirst": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1960,6 +1964,7 @@
|
||||
}
|
||||
},
|
||||
"home.heroSource.latestOnly": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -1976,6 +1981,7 @@
|
||||
}
|
||||
},
|
||||
"home.hideWatched": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -2120,6 +2126,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.headerExplanation": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Welche Tonspur und welche Untertitel beim Abspielen von selbst gewählt werden."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Which audio track and which subtitles are chosen automatically during playback."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages.serverDefault": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -2790,18 +2812,17 @@
|
||||
}
|
||||
},
|
||||
"miniPlayer.expand": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Vollbild"
|
||||
"value": "Auf Vollbild vergrößern"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Full screen"
|
||||
"value": "Expand to full screen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2840,6 +2861,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.airPlay": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auf Gerät übertragen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Play on another device"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.audioTracks": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -3276,6 +3313,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.audioShort": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ton"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Audio"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.autoShort": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.automatic": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
@@ -3393,6 +3462,7 @@
|
||||
}
|
||||
},
|
||||
"quality.fullHD": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -3409,6 +3479,7 @@
|
||||
}
|
||||
},
|
||||
"quality.hd": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -3477,6 +3548,7 @@
|
||||
}
|
||||
},
|
||||
"quality.lowRes": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -3561,6 +3633,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.originalShort": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Max"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Max"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtime.hoursMinutes %lld %lld": {
|
||||
"comment": "Laufzeit mit Stunden",
|
||||
"extractionState": "stale",
|
||||
@@ -3834,6 +3922,7 @@
|
||||
}
|
||||
},
|
||||
"settings.concurrent": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -3982,6 +4071,7 @@
|
||||
}
|
||||
},
|
||||
"settings.defaultRate": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4094,6 +4184,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.downloadsHeaderExplanation": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wie viel Platz Offline-Titel belegen dürfen, wie viele gleichzeitig laden und wann sie wieder verschwinden."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "How much space offline titles may take, how many load at once, and when they disappear again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.episodes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -4111,6 +4217,7 @@
|
||||
}
|
||||
},
|
||||
"settings.forward": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4197,6 +4304,7 @@
|
||||
}
|
||||
},
|
||||
"settings.lockLandscape": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4229,7 +4337,6 @@
|
||||
}
|
||||
},
|
||||
"settings.playback": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4245,6 +4352,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.playbackExplanation": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Wie gut das Bild sein soll, wie weit der Doppeltipp springt und was am Ende einer Folge passiert."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "How good the picture should be, how far a double tap jumps, and what happens at the end of an episode."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.quality": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
@@ -4312,6 +4435,7 @@
|
||||
}
|
||||
},
|
||||
"settings.rateNormal": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4328,6 +4452,7 @@
|
||||
}
|
||||
},
|
||||
"settings.rememberQuality": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4360,6 +4485,7 @@
|
||||
}
|
||||
},
|
||||
"settings.rewind": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4391,7 +4517,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings.seconds %@": {
|
||||
"comment": "Ein Picker, das die Anzahl der Sekunden zeigt, die man zurückgehen kann.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"settings.seconds %lld": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4408,6 +4539,7 @@
|
||||
}
|
||||
},
|
||||
"settings.section.look": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4424,6 +4556,7 @@
|
||||
}
|
||||
},
|
||||
"settings.section.playing": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4440,7 +4573,6 @@
|
||||
}
|
||||
},
|
||||
"settings.server": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
@@ -4491,6 +4623,7 @@
|
||||
}
|
||||
},
|
||||
"settings.showBitrate": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
/// Der Knopf, der die Empfängerliste öffnet.
|
||||
///
|
||||
/// Kein eigener Knopf mit eigener Liste: `AVRoutePickerView` ist die Stelle,
|
||||
/// die das System dafür vorsieht, und sie kennt Geräte, die eine App nie
|
||||
/// aufzählen könnte — Apple TVs, AirPlay-Lautsprecher, HomePods im Verbund.
|
||||
/// Sie nachzubauen hieße, eine unvollständige Liste zu zeigen.
|
||||
struct AirPlayButton: UIViewRepresentable {
|
||||
let tint: Color
|
||||
|
||||
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||
let view = AVRoutePickerView()
|
||||
view.tintColor = .white
|
||||
view.activeTintColor = UIColor(tint)
|
||||
// Bild vor Ton: bei einem Film soll die Liste die Fernseher zuerst
|
||||
// zeigen, nicht die Lautsprecher.
|
||||
view.prioritizesVideoDevices = true
|
||||
view.setContentHuggingPriority(.required, for: .horizontal)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: AVRoutePickerView, context: Context) {
|
||||
view.activeTintColor = UIColor(tint)
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,26 @@ struct MiniPlayer: View {
|
||||
playback.close()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
// Ein eigener Knopf, obwohl auch das Antippen der Fläche
|
||||
// vergrößert. Beim Ziehen und Aufziehen liegen auf derselben
|
||||
// Fläche schon zwei Gesten, und was daneben geht, sieht aus
|
||||
// wie „reagiert nicht". Ein Knopf geht nicht daneben.
|
||||
tileButton(
|
||||
"arrow.up.left.and.arrow.down.right",
|
||||
label: "miniPlayer.expand"
|
||||
) {
|
||||
playback.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(FlixySpacing.tight)
|
||||
// Über dem Fortschrittsbalken, nicht darauf.
|
||||
.padding(.bottom, FlixySpacing.tight)
|
||||
}
|
||||
|
||||
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
|
||||
|
||||
@@ -77,6 +77,27 @@ final class PlayerModel {
|
||||
/// Ob das Bild gezeigt werden soll.
|
||||
var showsVideo: Bool { quality.showsVideo }
|
||||
|
||||
/// Die eingestellte Stufe in zwei bis fünf Zeichen.
|
||||
///
|
||||
/// Steht auf dem Knopf im Player. „Automatisch" heißt dabei: was gerade
|
||||
/// tatsächlich ankommt — bei einem HLS-Strom entscheidet der Player selbst,
|
||||
/// und ihn danach zu fragen ist ehrlicher, als „Auto" hinzuschreiben und
|
||||
/// die Antwort schuldig zu bleiben.
|
||||
var qualityLabel: String {
|
||||
switch quality {
|
||||
case .automatic:
|
||||
if let height = statistics?.height, height > 0 { return "\(height)p" }
|
||||
return String(localized: "quality.autoShort")
|
||||
case .original:
|
||||
if let height = currentMedia?.pixelHeight { return "\(height)p" }
|
||||
return String(localized: "quality.originalShort")
|
||||
case .limited(let rung):
|
||||
return "\(rung.height)p"
|
||||
case .audioOnly:
|
||||
return String(localized: "quality.audioShort")
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Bildebene des Systemplayers.
|
||||
///
|
||||
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
|
||||
|
||||
@@ -23,7 +23,7 @@ struct PlayerView: View {
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var showsControls = true
|
||||
@State private var showsTrackPicker = false
|
||||
@State private var trackPicker: TrackPickerSheet.Kind?
|
||||
@State private var showsQualityChoice = false
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var scrubbing: Double?
|
||||
@@ -108,8 +108,8 @@ struct PlayerView: View {
|
||||
// Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt
|
||||
// die Bedienelemente wie bisher.
|
||||
.onTapGesture { toggleControls() }
|
||||
.sheet(isPresented: $showsTrackPicker) {
|
||||
TrackPickerSheet(model: model)
|
||||
.sheet(item: $trackPicker) { kind in
|
||||
TrackPickerSheet(model: model, kind: kind)
|
||||
}
|
||||
.sheet(isPresented: $showsQualityChoice) {
|
||||
QualitySheet(model: model)
|
||||
@@ -184,16 +184,33 @@ 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"))
|
||||
// Getrennte Knöpfe, weil es zwei verschiedene Fragen sind: die
|
||||
// Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
|
||||
// Film. Ein gemeinsames Blatt bedeutete jedes Mal Scrollen an der
|
||||
// anderen Liste vorbei.
|
||||
if !model.audioTracks.isEmpty {
|
||||
trackButton(
|
||||
symbol: "waveform",
|
||||
label: "player.audioTracks",
|
||||
kind: .audio
|
||||
)
|
||||
}
|
||||
|
||||
if !model.subtitleTracks.isEmpty {
|
||||
trackButton(
|
||||
symbol: model.selectedSubtitleTrack == nil
|
||||
? "captions.bubble"
|
||||
: "captions.bubble.fill",
|
||||
label: "player.subtitleTracks",
|
||||
kind: .subtitles
|
||||
)
|
||||
}
|
||||
|
||||
if model.engine?.supportsAirPlay == true {
|
||||
AirPlayButton(tint: accent)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(FlixySpacing.snug)
|
||||
.accessibilityLabel(Text("player.airPlay"))
|
||||
}
|
||||
|
||||
if !model.isOffline {
|
||||
@@ -203,6 +220,22 @@ struct PlayerView: View {
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private func trackButton(
|
||||
symbol: String,
|
||||
label: LocalizedStringKey,
|
||||
kind: TrackPickerSheet.Kind
|
||||
) -> some View {
|
||||
Button {
|
||||
trackPicker = kind
|
||||
hideTask?.cancel()
|
||||
} label: {
|
||||
Image(systemName: symbol)
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
|
||||
/// Der Knopf für die Qualitätsstufe.
|
||||
///
|
||||
/// Ein `Menu` stand hier vorher — und war der Grund dafür, dass sich der
|
||||
@@ -218,8 +251,18 @@ struct PlayerView: View {
|
||||
if model.isChangingQuality {
|
||||
ProgressView().controlSize(.small).tint(.white)
|
||||
} else {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.title3)
|
||||
// Die Stufe **als Text**, nicht als Symbol. Drei Schieberegler
|
||||
// sagen nicht, was eingestellt ist; „1080p" schon — und genau
|
||||
// das ist die Frage, wegen der man hier hintippt.
|
||||
Text(model.qualityLabel)
|
||||
.font(.flixyCaption.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
.padding(.vertical, FlixySpacing.tight)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FlixyRadius.card)
|
||||
.strokeBorder(.white.opacity(0.5), lineWidth: 1)
|
||||
}
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,28 @@ import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
|
||||
/// Auswahl von Ton- und Untertitelspur.
|
||||
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
|
||||
///
|
||||
/// Getrennt, weil es zwei verschiedene Fragen sind: die Tonspur wechselt man
|
||||
/// einmal am Anfang, die Untertitel mitten im Film. Beide in einem Blatt
|
||||
/// bedeutete jedes Mal Scrollen an der anderen Liste vorbei.
|
||||
struct TrackPickerSheet: View {
|
||||
enum Kind: Identifiable {
|
||||
case audio
|
||||
case subtitles
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: LocalizedStringKey {
|
||||
switch self {
|
||||
case .audio: "player.audioTracks"
|
||||
case .subtitles: "player.subtitleTracks"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let model: PlayerModel
|
||||
let kind: Kind
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@@ -12,45 +31,44 @@ struct TrackPickerSheet: View {
|
||||
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)
|
||||
}
|
||||
switch kind {
|
||||
case .audio:
|
||||
ForEach(model.audioTracks) { track in
|
||||
row(
|
||||
title: track.displayName,
|
||||
isSelected: model.selectedAudioTrack == track
|
||||
) {
|
||||
model.selectAudioTrack(track)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
case .subtitles:
|
||||
// „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)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
ForEach(model.subtitleTracks) { track in
|
||||
row(
|
||||
title: String(localized: "player.subtitlesOff"),
|
||||
isSelected: model.selectedSubtitleTrack == nil
|
||||
title: track.displayName,
|
||||
isSelected: model.selectedSubtitleTrack == track
|
||||
) {
|
||||
model.selectSubtitleTrack(nil)
|
||||
}
|
||||
|
||||
ForEach(model.subtitleTracks) { track in
|
||||
row(
|
||||
title: track.displayName,
|
||||
isSelected: model.selectedSubtitleTrack == track
|
||||
) {
|
||||
model.selectSubtitleTrack(track)
|
||||
}
|
||||
model.selectSubtitleTrack(track)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("player.tracks"))
|
||||
.navigationTitle(Text(kind.title))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
|
||||
@@ -17,6 +17,13 @@ struct DownloadSettingsView: View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "arrow.down.circle",
|
||||
colour: SettingsPalette.downloads,
|
||||
title: "sidebar.downloads",
|
||||
explanation: "settings.downloadsHeaderExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ struct LanguagesView: View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "captions.bubble",
|
||||
colour: SettingsPalette.languages,
|
||||
title: "languages.title",
|
||||
explanation: "languages.headerExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("languages.audio", selection: $settings.preferredAudioLanguage) {
|
||||
Text("languages.serverDefault").tag("")
|
||||
|
||||
@@ -15,6 +15,13 @@ struct PlaybackSettingsView: View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "play.rectangle",
|
||||
colour: SettingsPalette.playback,
|
||||
title: "settings.playback",
|
||||
explanation: "settings.playbackExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("settings.qualityWifi", selection: $settings.wifiQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
|
||||
/// Das farbige Symbol vor einer Einstellungszeile.
|
||||
///
|
||||
/// Nicht Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das
|
||||
/// Auge nichts wieder. Eine Farbe je Bereich macht „das mit dem blauen Zahnrad"
|
||||
/// zu einer brauchbaren Beschreibung — und genau so sucht man Einstellungen,
|
||||
/// die man schon einmal gesehen hat.
|
||||
///
|
||||
/// Die Farben sind fest und **nicht** die Akzentfarbe: wenn alles die gleiche
|
||||
/// Farbe trägt, unterscheidet sie nichts mehr.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kopfkarte eines Einstellungsbildschirms.
|
||||
///
|
||||
/// Sagt in einem Satz, worum es hier geht. Das klingt nach Zierde, ist aber
|
||||
/// der Unterschied zwischen „Wiedergabe" und „hier stelle ich ein, wie weit
|
||||
/// der Doppeltipp springt" — und erspart das Durchprobieren aller Schalter.
|
||||
struct SettingsHeader: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
let title: LocalizedStringKey
|
||||
let explanation: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
||||
|
||||
Text(title)
|
||||
.font(.flixyTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
|
||||
Text(explanation)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Farben der Bereiche, an einer Stelle.
|
||||
enum SettingsPalette {
|
||||
static let server = Color.gray
|
||||
static let appearance = Color.blue
|
||||
static let home = Color.green
|
||||
static let playback = Color.pink
|
||||
static let languages = Color.teal
|
||||
static let downloads = Color.indigo
|
||||
static let diagnostics = Color.orange
|
||||
}
|
||||
@@ -25,25 +25,41 @@ struct SettingsView: View {
|
||||
Text(session.serverName)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
} label: {
|
||||
Label("settings.server", systemImage: "externaldrive.connected.to.line.below")
|
||||
row(
|
||||
"settings.server",
|
||||
symbol: "externaldrive.connected.to.line.below",
|
||||
colour: SettingsPalette.server
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.look") {
|
||||
row("appearance.title", symbol: "paintpalette") { AppearanceView() }
|
||||
row("homeLayout.title", symbol: "rectangle.stack") { HomeLayoutView() }
|
||||
link("appearance.title", symbol: "paintpalette", colour: SettingsPalette.appearance) {
|
||||
AppearanceView()
|
||||
}
|
||||
link("homeLayout.title", symbol: "rectangle.stack", colour: SettingsPalette.home) {
|
||||
HomeLayoutView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.playing") {
|
||||
row("settings.playback", symbol: "play.rectangle") { PlaybackSettingsView() }
|
||||
row("languages.title", symbol: "captions.bubble") { LanguagesView() }
|
||||
row("sidebar.downloads", symbol: "arrow.down.circle") { DownloadSettingsView() }
|
||||
link("settings.playback", symbol: "play.rectangle", colour: SettingsPalette.playback) {
|
||||
PlaybackSettingsView()
|
||||
}
|
||||
link("languages.title", symbol: "captions.bubble", colour: SettingsPalette.languages) {
|
||||
LanguagesView()
|
||||
}
|
||||
link("sidebar.downloads", symbol: "arrow.down.circle", colour: SettingsPalette.downloads) {
|
||||
DownloadSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
row("diagnostics.title", symbol: "stethoscope") { DiagnosticsView() }
|
||||
link("diagnostics.title", symbol: "stethoscope", colour: SettingsPalette.diagnostics) {
|
||||
DiagnosticsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
@@ -80,15 +96,23 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func row<Destination: View>(
|
||||
private func link<Destination: View>(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
colour: Color,
|
||||
@ViewBuilder destination: @escaping () -> Destination
|
||||
) -> some View {
|
||||
NavigationLink {
|
||||
destination()
|
||||
} label: {
|
||||
Label(title, systemImage: symbol)
|
||||
row(title, symbol: symbol, colour: colour)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
SettingsIcon(symbol: symbol, colour: colour)
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,16 @@ public struct HeroBanner<Actions: View>: View {
|
||||
|
||||
public var body: some View {
|
||||
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
||||
// Das Bild läuft über seinen Bereich hinaus — unter die
|
||||
// Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die
|
||||
// Ränder und zeichnet sie weich weiter, statt das Bild zu
|
||||
// verzerren; der Bildinhalt selbst bleibt, wo er ist, und mit ihm
|
||||
// Titel, Beschreibung und Knöpfe.
|
||||
//
|
||||
// Ohne das endete der Aufmacher an der Kante der Seitenleiste, und
|
||||
// beim Weiterwandern schob sich das nächste Bild an einer harten
|
||||
// Linie ins Bild.
|
||||
.backgroundExtensionEffect()
|
||||
.overlay(alignment: .bottom) {
|
||||
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
|
||||
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
|
||||
|
||||
@@ -32,6 +32,7 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
}
|
||||
|
||||
public let supportsPictureInPicture = true
|
||||
public let supportsAirPlay = true
|
||||
|
||||
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
|
||||
AsyncStream { continuation in
|
||||
|
||||
@@ -54,6 +54,12 @@ public protocol VideoPlayerEngine: AnyObject {
|
||||
/// schlimmer als keiner.
|
||||
var supportsPictureInPicture: Bool { get }
|
||||
|
||||
/// Ob dieser Engine sein Bild an ein AirPlay-Gerät geben kann.
|
||||
///
|
||||
/// Nur der Systemplayer kann das. VLC zeichnet selbst und kennt die
|
||||
/// Empfängerliste nicht — ein Knopf dafür führte dort ins Leere.
|
||||
var supportsAirPlay: Bool { get }
|
||||
|
||||
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
|
||||
func play()
|
||||
func pause()
|
||||
@@ -114,4 +120,7 @@ public struct PlaybackStatistics: Sendable, Equatable {
|
||||
extension VideoPlayerEngine {
|
||||
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
|
||||
public var statistics: PlaybackStatistics? { nil }
|
||||
|
||||
/// Wer nichts dazu sagt, kann es nicht.
|
||||
public var supportsAirPlay: Bool { false }
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ def png(colour, width=960, height=540):
|
||||
|
||||
def colour_for(key):
|
||||
h = hashlib.sha1(key.encode()).digest()
|
||||
# Gedeckte Töne, damit weiße Schrift darauf lesbar bleibt.
|
||||
return (40 + h[0] % 90, 40 + h[1] % 90, 50 + h[2] % 90)
|
||||
# Kräftig statt gedeckt: an blassen Flächen lässt sich nicht erkennen, ob
|
||||
# ein Bild tatsächlich hinter der Seitenleiste weiterläuft.
|
||||
return (60 + h[0] % 190, 40 + h[1] % 150, 60 + h[2] % 190)
|
||||
|
||||
POLLS = {"count": 0}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user