diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 01fb3d4..e133dcb 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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": { diff --git a/Flixy/Views/Player/AirPlayButton.swift b/Flixy/Views/Player/AirPlayButton.swift new file mode 100644 index 0000000..0e596c2 --- /dev/null +++ b/Flixy/Views/Player/AirPlayButton.swift @@ -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) + } +} diff --git a/Flixy/Views/Player/MiniPlayer.swift b/Flixy/Views/Player/MiniPlayer.swift index b4e0fa9..81147e0 100644 --- a/Flixy/Views/Player/MiniPlayer.swift +++ b/Flixy/Views/Player/MiniPlayer.swift @@ -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. diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 283d73b..c5a0371 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -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 diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index d927ebe..3388d4a 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -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? @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) } } diff --git a/Flixy/Views/Player/TrackPickerSheet.swift b/Flixy/Views/Player/TrackPickerSheet.swift index 17d202b..f7ffee3 100644 --- a/Flixy/Views/Player/TrackPickerSheet.swift +++ b/Flixy/Views/Player/TrackPickerSheet.swift @@ -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) { diff --git a/Flixy/Views/Settings/DownloadSettingsView.swift b/Flixy/Views/Settings/DownloadSettingsView.swift index 60332d9..b2da1d6 100644 --- a/Flixy/Views/Settings/DownloadSettingsView.swift +++ b/Flixy/Views/Settings/DownloadSettingsView.swift @@ -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) diff --git a/Flixy/Views/Settings/LanguagesView.swift b/Flixy/Views/Settings/LanguagesView.swift index e8da393..90f64f3 100644 --- a/Flixy/Views/Settings/LanguagesView.swift +++ b/Flixy/Views/Settings/LanguagesView.swift @@ -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("") diff --git a/Flixy/Views/Settings/PlaybackSettingsView.swift b/Flixy/Views/Settings/PlaybackSettingsView.swift index 44a65f4..494ff81 100644 --- a/Flixy/Views/Settings/PlaybackSettingsView.swift +++ b/Flixy/Views/Settings/PlaybackSettingsView.swift @@ -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 diff --git a/Flixy/Views/Settings/SettingsChrome.swift b/Flixy/Views/Settings/SettingsChrome.swift new file mode 100644 index 0000000..2f4a838 --- /dev/null +++ b/Flixy/Views/Settings/SettingsChrome.swift @@ -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 +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 577e423..2d919d7 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -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( + private func link( _ 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) } } } diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 20a41c2..9fbae56 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -79,6 +79,16 @@ public struct HeroBanner: 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 diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index 3bcff96..654bd51 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -32,6 +32,7 @@ public final class AVPlayerEngine: VideoPlayerEngine { } public let supportsPictureInPicture = true + public let supportsAirPlay = true public private(set) lazy var events: AsyncStream = { AsyncStream { continuation in diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift index 28f04c0..b98268f 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -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 } } diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index 0e5444b..720e27f 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -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}