diff --git a/.gitignore b/.gitignore index 6f82855..456abb2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ xcuserdata/ # Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine # Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate +# +# Flixy.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu: +# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das +# nötig ist, steht in Flixy.xcworkspace/README.md. Flixy.xcodeproj/ diff --git a/Flixy.xcworkspace/README.md b/Flixy.xcworkspace/README.md new file mode 100644 index 0000000..fe3028d --- /dev/null +++ b/Flixy.xcworkspace/README.md @@ -0,0 +1,19 @@ +# Diesen Arbeitsbereich öffnen, nicht das Projekt + +`Flixy.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei +**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode, +auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im** +Projekt offen (`Flixy.xcodeproj/project.xcworkspace`), meldet es danach + +> The workspace file … has disappeared. Do you want to re-save the container, +> or close it? + +und zwar nach jedem einzelnen Lauf. + +Dieser Arbeitsbereich liegt **daneben** statt darin. Er verweist nur auf das +Projekt und wird von XcodeGen nie angefasst — Xcode lädt das Projekt danach +still nach, ohne zu fragen. + +**Falls der Dialog doch einmal kommt: „Close", nie „Re-save".** Re-save +schreibt Xcodes Kopie aus dem Arbeitsspeicher über das frisch erzeugte +Projekt; alle Änderungen aus `project.yml` wären damit wieder weg. diff --git a/Flixy.xcworkspace/contents.xcworkspacedata b/Flixy.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..ae372ab --- /dev/null +++ b/Flixy.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,4 @@ + + + + diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 6df1172..cd41544 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1975,7 +1975,6 @@ } }, "home.empty.title": { - "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -3841,6 +3840,40 @@ } } }, + "player.rebuilding": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stream wird neu aufgebaut …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rebuilding stream …" + } + } + } + }, + "player.rebuilds": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stream wird neu aufgebaut" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stream will be rebuilt" + } + } + } + }, "player.relayWarning": { "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", "extractionState": "stale", diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 941a540..a15eeb6 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -4,7 +4,9 @@ import MediaKit import Observation import FlixyDownloads import FlixyPlayback -#if canImport(FlixyVLC) +// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der +// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird. +#if canImport(FlixyVLC) && !os(tvOS) import FlixyVLC #endif import PlexKit @@ -154,6 +156,17 @@ final class PlayerModel { /// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an. let surface = PlayerLayerView() + /// Der Systemplayer der laufenden Engine — oder nichts, solange keine läuft. + /// + /// **AVFoundation, nicht UIKit.** Das Modell weiss weiterhin nicht, wer + /// das Bild zeichnet: auf dem Telefon hängt die gehaltene `AVPlayerLayer` + /// daran, auf dem Fernseher ein `AVPlayerViewController`. Beide holen sich + /// hier dasselbe ab. + /// + /// `nil` bei VLC — kein Sonderfall, sondern dieselbe Auskunft, die + /// `VideoSurface` heute schon auswertet. + var systemPlayer: AVPlayer? { (engine as? AVPlayerEngine)?.player } + /// Wie das Bild in die Fläche gelegt wird. /// /// **Im Modell und nicht in der Ansicht**, aus demselben Grund wie die @@ -201,7 +214,14 @@ final class PlayerModel { /// gibt, steht hier kein Plex-Server mehr, sondern wer auch immer /// Live-TV kann. private let liveTV: (any LiveTVBackend)? - private let downloads: DownloadCoordinator + /// Der Bestand auf der Platte — auf dem Fernseher gibt es keinen. + /// + /// **Optional und nicht ein leerer Koordinator.** Ein leerer bräuchte + /// einen `ModelContainer`, und der ist das Einzige, wofür diese App + /// SwiftData überhaupt benutzt; auf tvOS bliebe damit beides weg. Ein + /// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die + /// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden. + private let downloads: DownloadCoordinator? private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings @@ -454,7 +474,7 @@ final class PlayerModel { request: PlaybackRequest, backend: (any MediaBackend)?, liveTV: (any LiveTVBackend)?, - downloads: DownloadCoordinator, + downloads: DownloadCoordinator? = nil, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, @@ -491,7 +511,7 @@ final class PlayerModel { // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein // Datenvolumen und funktioniert auch dann, wenn der Server gerade // nicht erreichbar ist — der eigentliche Sinn des Herunterladens. - if let localURL = downloads.localURL(forRatingKey: request.ratingKey) { + if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) { await startLocal(url: localURL) return } @@ -713,7 +733,7 @@ final class PlayerModel { } private func startLocal(url: URL) async { - let offline = downloads.offlineMedia(forRatingKey: request.ratingKey) + let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil let decision = PlaybackDecider().decide( // Früher standen hier **erfundene** Plex-Modelle: eine Media mit @@ -1746,7 +1766,7 @@ final class PlayerModel { } private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine { - #if canImport(FlixyVLC) + #if canImport(FlixyVLC) && !os(tvOS) switch decision.engine { case .vlc: VLCPlayerEngine() // Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und @@ -1842,9 +1862,11 @@ final class PlayerModel { // Grundlage fürs automatische Löschen nach dem Ansehen — // und der Auslöser fürs Nachladen: der Vorrat ist jetzt // um eine Folge kleiner. - downloads.markWatched(ratingKey: request.ratingKey) - Task { [backend, downloads] in - await DownloadRefiller(backend: backend, coordinator: downloads).refill() + if let downloads { + downloads.markWatched(ratingKey: request.ratingKey) + Task { [backend, downloads] in + await DownloadRefiller(backend: backend, coordinator: downloads).refill() + } } case .tracksResolved(let audio, let subtitle): audioTracks = audio diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index 97e5098..a6107ba 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -6,7 +6,9 @@ import UIKit #else import AppKit #endif -#if canImport(FlixyVLC) +// Siehe `PlaybackSettings`: `!os(tvOS)` muss dazu, sonst bricht der +// Abhängigkeits-Sucher an VLCKit ab, das für dieses Ziel gar nicht gebaut wird. +#if canImport(FlixyVLC) && !os(tvOS) import FlixyVLC #endif @@ -59,7 +61,7 @@ struct VideoSurface: View { var bottomInset: CGFloat = 0 var body: some View { - #if canImport(FlixyVLC) + #if canImport(FlixyVLC) && !os(tvOS) if let vlcEngine = engine as? VLCPlayerEngine { SurfaceContainer(view: vlcEngine.videoView, isActive: isActive, bottomInset: bottomInset) } else if engine is AVPlayerEngine { diff --git a/FlixyTV/Views/Library/TVLibraryView.swift b/FlixyTV/Views/Library/TVLibraryView.swift index 654bea8..f30385c 100644 --- a/FlixyTV/Views/Library/TVLibraryView.swift +++ b/FlixyTV/Views/Library/TVLibraryView.swift @@ -13,6 +13,8 @@ struct TVLibraryView: View { let libraryID: String @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(QuickPlay.self) private var quickPlay @State private var feed: LibraryFeed? @FocusState private var fokus: String? @@ -78,10 +80,17 @@ struct TVLibraryView: View { ), progress: item.watchProgress ) { - // In Fassung eins führt die Karte zur Detailseite. + // **Über QuickPlay, nicht unmittelbar.** Steht im + // Raster eine Serie, hat die keine Fassungen; der + // direkte Weg schickte ihre eigene Kennung an den + // Player und scheiterte mit „keine abspielbare + // Datei". QuickPlay löst stattdessen die nächste + // ungesehene Folge auf. + // // Ein Kontextmenü wie am Telefon gibt es hier // nicht: langes Drücken ist auf der Fernbedienung // keine verlässliche Geste. + quickPlay.start(item, backend: session.backend, coordinator: playback) } .focused($fokus, equals: item.id) .onChange(of: fokus) { _, neu in diff --git a/FlixyTV/Views/Player/TVPlayerCoordinator.swift b/FlixyTV/Views/Player/TVPlayerCoordinator.swift new file mode 100644 index 0000000..77a611f --- /dev/null +++ b/FlixyTV/Views/Player/TVPlayerCoordinator.swift @@ -0,0 +1,199 @@ +import AVKit +import UIKit +import FlixyPlayback +import MediaKit + +/// Führt Apples Player — und hält ihn über einen Neuaufbau hinweg am Leben. +/// +/// **Das Problem, um das es hier geht.** Unsere Ton- und Untertitelwahl geht +/// als `audioStreamIndex` an Jellyfin und verlangt bei einem Transcode einen +/// **neuen Stream**. Apples eingebauter Spurwähler zeigt dagegen die +/// `AVMediaSelectionGroup`s des **laufenden** Streams — beim Transcode genau +/// eine. Er ist also die richtige Oberfläche für die falsche Liste. +/// +/// Deshalb bekommt die Transportleiste unsere eigenen Menüs über +/// `transportBarCustomMenuItems`. Nicht `customInfoViewControllers`: die Wahl +/// der Tonspur ist ein Bedienelement der Wiedergabe, kein Begleitmaterial — +/// Apples eigene Beispiele dafür sind Bonusinhalte und Verwandtes, und dort +/// erwartet niemand „Sprache". +@MainActor +final class TVPlayerCoordinator: NSObject, @MainActor AVPlayerViewControllerDelegate { + let controller = AVPlayerViewController() + + private let onDismiss: () -> Void + private var letzteAufnahme: TVPlayerSnapshot? + /// Wacht darüber, wann es etwas zu beschriften gibt. + /// + /// **Nicht beim Austausch des Players.** In dem Moment ist `currentItem` + /// noch `nil` — das Item entsteht erst, wenn das Asset geladen ist, und + /// bis dahin zeigt Apples Titelzeile, was es selbst aus der Datei liest: + /// „Unbekannt". Also wird gewartet, bis das Item wirklich da ist. + private var itemBeobachter: NSKeyValueObservation? + /// Was drinstehen soll, sobald es ein Item gibt. + private var beschriftung: (titel: String, untertitel: String?)? + /// Die Abdeckung für das schwarze Bild während eines Neuaufbaus. + private lazy var abdeckung = TVRebuildOverlay() + private var abdeckungsAufgabe: Task? + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + super.init() + controller.delegate = self + controller.videoGravity = .resizeAspect + // Nie auf dem Fernseher — ein Apple TV hat kein zweites Fenster. + controller.allowsPictureInPicturePlayback = false + controller.transportBarIncludesTitleView = true + + // **Hinter den Bedienelementen und ohne Fokus.** `contentOverlayView` + // liegt über dem Bild, aber unter Apples Leiste, und nimmt keine + // Eingaben an. `customOverlayViewController` täte beides nicht: der + // ersetzt die ganze Überlagerung und zieht den Fokus an sich — damit + // wäre die Fernbedienung während des Umschaltens tot. + if let ebene = controller.contentOverlayView { + abdeckung.translatesAutoresizingMaskIntoConstraints = false + ebene.addSubview(abdeckung) + NSLayoutConstraint.activate([ + abdeckung.topAnchor.constraint(equalTo: ebene.topAnchor), + abdeckung.bottomAnchor.constraint(equalTo: ebene.bottomAnchor), + abdeckung.leadingAnchor.constraint(equalTo: ebene.leadingAnchor), + abdeckung.trailingAnchor.constraint(equalTo: ebene.trailingAnchor), + ]) + } + } + + // MARK: - Nachführen + + func apply(_ aufnahme: TVPlayerSnapshot, model: PlayerModel) { + defer { letzteAufnahme = aufnahme } + + // **Den Player nur austauschen, wenn es ein anderer ist.** `restart()` + // baut eine neue Engine mit einem neuen `AVPlayer`; genau dann — und + // nur dann — wird das Bild schwarz. + if aufnahme.playerIdentity != letzteAufnahme?.playerIdentity { + controller.player = model.systemPlayer + beobachteItem() + } + beschriftung = (aufnahme.title, aufnahme.subtitle) + beschrifte() + + zeigeAbdeckung(aufnahme.isRebuilding) + controller.transportBarCustomMenuItems = TVTrackMenuBuilder.items( + for: aufnahme, + onAudio: { [weak model] index in + guard let model, let stream = model.audioStreams.first(where: { $0.index == index }) + else { return } + Task { await model.selectAudioStream(stream) } + }, + onSubtitle: { [weak model] index in + guard let model else { return } + let stream = index.flatMap { i in + model.subtitleStreams.first { $0.index == i } + } + Task { await model.selectSubtitleStream(stream) } + } + ) + controller.contextualActions = TVTrackMenuBuilder.contextualActions( + for: aufnahme, + onSkip: { [weak model] in model?.skipActiveMarker() } + ) + } + + private func beobachteItem() { + itemBeobachter = controller.player?.observe(\.currentItem, options: [.initial, .new]) { + [weak self] _, _ in + MainActor.assumeIsolated { self?.beschrifte() } + } + } + + /// Titel und Untertitel in Apples Titelzeile. + /// + /// Über `externalMetadata` am `AVPlayerItem`, weil die Transportleiste + /// genau dort nachsieht. Auf iOS zeichnet unsere eigene Kopfzeile das; + /// hier gäbe es sonst gar keine Auskunft darüber, was läuft — ausser dem, + /// was AVKit selbst aus der Datei liest, und das ist bei einem + /// Transcode-Stream „Unbekannt". + private func beschrifte() { + guard let item = controller.player?.currentItem, + let beschriftung + else { return } + var einträge: [AVMetadataItem] = [eintrag(.commonIdentifierTitle, beschriftung.titel)] + if let untertitel = beschriftung.untertitel { + einträge.append(eintrag(.iTunesMetadataTrackSubTitle, untertitel)) + } + item.externalMetadata = einträge + } + + private func eintrag(_ kennung: AVMetadataIdentifier, _ wert: String) -> AVMetadataItem { + let eintrag = AVMutableMetadataItem() + eintrag.identifier = kennung + eintrag.value = wert as NSString + eintrag.extendedLanguageTag = "und" + return eintrag + } + + /// Blendet die Abdeckung ein — aber erst nach einer Viertelsekunde. + /// + /// Der häufige Fall (unverändert gespielt, unter fünfzig Millisekunden) + /// soll nicht aufblitzen. Wer nur umschaltet, sieht dann gar nichts. + private func zeigeAbdeckung(_ sichtbar: Bool) { + abdeckungsAufgabe?.cancel() + guard sichtbar else { + abdeckung.setVisible(false) + return + } + abdeckungsAufgabe = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + self?.abdeckung.setVisible(true) + } + } + + // MARK: - Der Menü-Knopf der Fernbedienung + + /// Fängt den Menü-Knopf ab, statt in SwiftUI `onExitCommand` zu raten. + func playerViewControllerShouldDismiss(_ controller: AVPlayerViewController) -> Bool { + onDismiss() + return true + } +} + +/// Die Abdeckung, die das schwarze Bild während eines Neuaufbaus zudeckt. +/// +/// Ohne sie sieht man Sekunden lang nichts und hält es für einen Absturz — +/// dabei ist es die angekündigte Folge einer Spurwahl. +@MainActor +final class TVRebuildOverlay: UIView { + private let kreisel = UIActivityIndicatorView(style: .large) + private let zeile = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.black.withAlphaComponent(0.75) + isUserInteractionEnabled = false + alpha = 0 + + kreisel.color = .white + kreisel.startAnimating() + zeile.text = String(localized: "player.rebuilding") + zeile.textColor = .white + zeile.font = .preferredFont(forTextStyle: .headline) + + let stapel = UIStackView(arrangedSubviews: [kreisel, zeile]) + stapel.axis = .vertical + stapel.alignment = .center + stapel.spacing = 24 + stapel.translatesAutoresizingMaskIntoConstraints = false + addSubview(stapel) + NSLayoutConstraint.activate([ + stapel.centerXAnchor.constraint(equalTo: centerXAnchor), + stapel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("nicht aus einem Storyboard") } + + func setVisible(_ sichtbar: Bool) { + UIView.animate(withDuration: 0.2) { self.alpha = sichtbar ? 1 : 0 } + } +} diff --git a/FlixyTV/Views/Player/TVPlayerHost.swift b/FlixyTV/Views/Player/TVPlayerHost.swift new file mode 100644 index 0000000..22b86cb --- /dev/null +++ b/FlixyTV/Views/Player/TVPlayerHost.swift @@ -0,0 +1,79 @@ +import AVKit +import SwiftUI +import FlixyPlayback +import MediaKit + +/// Hängt Apples Player in den SwiftUI-Baum. +/// +/// **Wem was gehört.** Der `AVPlayerViewController` gehört dem Coordinator, +/// nicht der SwiftUI-Struktur — die wird bei jedem Neuzeichnen neu gebaut — +/// und nicht dem `PlayerModel`, das frei von UIKit bleiben soll. Das Modell +/// gibt nur seinen `AVPlayer` heraus, und das ist AVFoundation. +@MainActor +struct TVPlayerHost: UIViewControllerRepresentable { + let model: PlayerModel + /// Eine Momentaufnahme dessen, was den Controller betrifft. + /// + /// **Als Wert, nicht als Verweis.** SwiftUI ruft `updateUIViewController` + /// nur, wenn sich am Repräsentanten etwas geändert hat. Stünde hier allein + /// das Modell, änderte sich nie etwas: es ist eine Klasse, und ihre + /// Kennung bleibt über jeden Spurwechsel hinweg dieselbe. Der Wert wird im + /// Rumpf der umgebenden Ansicht gebildet — dort und nur dort entsteht die + /// Beobachtungsbeziehung zu `@Observable`. + let snapshot: TVPlayerSnapshot + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> AVPlayerViewController { + context.coordinator.controller + } + + func updateUIViewController(_ controller: AVPlayerViewController, context: Context) { + context.coordinator.apply(snapshot, model: model) + } + + func makeCoordinator() -> TVPlayerCoordinator { + TVPlayerCoordinator(onDismiss: onDismiss) + } +} + +/// Was der Controller von der Wiedergabe wissen muss — und mehr nicht. +struct TVPlayerSnapshot: Equatable { + /// Wechselt bei jedem Neuaufbau. Daran hängt der Austausch des Players. + let playerIdentity: ObjectIdentifier? + let audio: [TrackMenu.Entry] + let subtitles: [TrackMenu.Entry] + /// Läuft gerade ein Neuaufbau? Dann ist das Bild schwarz und das Menü + /// gesperrt. + let isRebuilding: Bool + let title: String + let subtitle: String? + /// Der Zeitbereich einer Marke — für „Intro überspringen". + let markerLabel: String? + + /// `@MainActor`, weil das Modell es ist: gelesen wird hier zwölfmal aus + /// ihm, und zwar im Rumpf einer View — also ohnehin auf dem Hauptakteur. + @MainActor + init(_ model: PlayerModel, offLabel: String) { + playerIdentity = model.systemPlayer.map(ObjectIdentifier.init) + let direkt: Bool = if case .directPlay = model.decision?.method { true } else { false } + audio = TrackMenu.audio( + from: model.audioStreams, + selected: model.selectedAudioStream, + isDirectPlay: direkt, + playerTrackCount: model.audioTracks.count + ) + subtitles = TrackMenu.subtitles( + from: model.subtitleStreams, + selected: model.selectedSubtitleStream, + isDirectPlay: direkt, + playerTrackLanguages: model.subtitleTracks.map(\.languageCode), + offLabel: offLabel + ) + isRebuilding = model.isChangingQuality + title = model.title + subtitle = model.subtitle + markerLabel = model.activeMarker.map { + String(localized: $0.isIntro ? "player.skipIntro" : "player.skipCredits") + } + } +} diff --git a/FlixyTV/Views/Player/TVPlayerScreen.swift b/FlixyTV/Views/Player/TVPlayerScreen.swift new file mode 100644 index 0000000..6c874cc --- /dev/null +++ b/FlixyTV/Views/Player/TVPlayerScreen.swift @@ -0,0 +1,70 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback +import MediaKit + +/// Der Player auf dem Fernseher. +/// +/// **Das Modell lebt hier und stirbt hier.** Auf dem Telefon hält `RootView` +/// es fest, damit die Wiedergabe den Wechsel zwischen Kachel und Vollbild +/// überlebt. Ohne Mini-Player gibt es diesen Wechsel nicht, und ein +/// hochgehaltenes Modell hielte nur eine tote Engine, einen laufenden +/// Transcoder und eine offene Jellyfin-Sitzung am Leben, nachdem der Player +/// längst weg ist. +struct TVPlayerScreen: View { + let request: PlaybackRequest + + @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback + @Environment(PlaybackSettings.self) private var settings + @Environment(LanguagePreferences.self) private var languages + @Environment(SubtitleAppearance.self) private var subtitleAppearance + @Environment(NetworkConditions.self) private var network + @Environment(PlaybackDiagnostics.self) private var diagnostics + + @State private var model: PlayerModel? + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let model { + // **Die Momentaufnahme entsteht hier, im Rumpf einer View.** + // Nur so entsteht die Beobachtungsbeziehung zu `@Observable` + // — ein Repräsentant, der bloss das Modell hält, bekäme nie + // ein `updateUIViewController`. + TVPlayerHost( + model: model, + snapshot: TVPlayerSnapshot(model, offLabel: String(localized: "player.subtitlesOff")), + onDismiss: { playback.close() } + ) + .ignoresSafeArea() + } else { + ProgressView() + } + } + .task { + guard model == nil else { return } + let frisch = PlayerModel( + request: request, + backend: session.backend, + liveTV: nil, // Kein Live-TV in Fassung eins. + downloads: nil, // Nie auf dem Fernseher. + diagnostics: diagnostics, + policy: settings.policy(for: network), + settings: settings, + languages: languages, + subtitles: subtitleAppearance, + isLocalConnection: session.isLocalConnection, + connectionDescription: session.connectionDescription, + unavailableMessage: session.playbackUnavailableMessage + ) + model = frisch + await frisch.start() + } + // Beim Verschwinden wirklich Schluss: Engine abräumen, Fortschritt + // melden, die Jellyfin-Sitzung schliessen. Ohne das liefe der + // Transcoder auf dem Server weiter, obwohl niemand mehr zusieht. + .onDisappear { [model] in Task { await model?.stop() } } + } +} diff --git a/FlixyTV/Views/Player/TVTrackMenuBuilder.swift b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift new file mode 100644 index 0000000..513a2e5 --- /dev/null +++ b/FlixyTV/Views/Player/TVTrackMenuBuilder.swift @@ -0,0 +1,85 @@ +import AVKit +import UIKit +import FlixyPlayback + +/// Baut aus unseren Spurlisten die Menüs für Apples Transportleiste. +/// +/// Die reine Abbildung steht in `TrackMenu` und ist dort geprüft; hier geht es +/// nur noch um UIKit. +@MainActor +enum TVTrackMenuBuilder { + static func items( + for aufnahme: TVPlayerSnapshot, + onAudio: @escaping (Int?) -> Void, + onSubtitle: @escaping (Int?) -> Void + ) -> [UIMenuElement] { + var menüs: [UIMenuElement] = [] + if aufnahme.audio.count > 1 { + menüs.append(menü( + titel: String(localized: "player.audioTracks"), + symbol: "waveform", + einträge: aufnahme.audio, + gesperrt: aufnahme.isRebuilding, + aktion: onAudio + )) + } + if aufnahme.subtitles.count > 1 { + menüs.append(menü( + titel: String(localized: "player.subtitleTracks"), + symbol: "captions.bubble", + einträge: aufnahme.subtitles, + gesperrt: aufnahme.isRebuilding, + aktion: onSubtitle + )) + } + return menüs + } + + private static func menü( + titel: String, + symbol: String, + einträge: [TrackMenu.Entry], + gesperrt: Bool, + aktion: @escaping (Int?) -> Void + ) -> UIMenu { + UIMenu( + title: titel, + image: UIImage(systemName: symbol), + options: [.singleSelection], + children: einträge.map { eintrag in + UIAction( + title: eintrag.title, + // **Vorher ansagen, nicht hinterher erklären.** Steht hier + // nichts, ist die Sekundenpause danach eine Panne; steht + // es hier, ist sie eine angekündigte Folge. Das ist der + // billigste Teil dieser ganzen Sache und der wirksamste. + subtitle: eintrag.rebuilds + ? String(localized: "player.rebuilds") + : nil, + // **Keine zweite Bestellung, solange die erste läuft.** + // Sonst räumt `restart()` eine Engine ab, die eine andere + // gerade baut. + attributes: gesperrt ? [.disabled] : [], + state: eintrag.isSelected ? .on : .off + ) { auslöser in + // **Sofort quittieren.** Das Menü schliesst sich beim + // Drücken, und darin ist kein Platz für einen Kreisel. + // Der Haken wandert, bevor sich am Bild etwas tut. + auslöser.state = .on + aktion(eintrag.id) + } + } + ) + } + + /// „Intro überspringen" unten rechts, solange eine Marke läuft. + /// + /// Die tvOS-Entsprechung zu dem Knopf, den der iOS-Player selbst zeichnet. + static func contextualActions( + for aufnahme: TVPlayerSnapshot, + onSkip: @escaping () -> Void + ) -> [UIAction] { + guard let beschriftung = aufnahme.markerLabel else { return [] } + return [UIAction(title: beschriftung) { _ in onSkip() }] + } +} diff --git a/FlixyTV/Views/TVRootView.swift b/FlixyTV/Views/TVRootView.swift index 25ac5e8..700abb9 100644 --- a/FlixyTV/Views/TVRootView.swift +++ b/FlixyTV/Views/TVRootView.swift @@ -11,6 +11,7 @@ import FlixyDesign /// in derselben Datei. struct TVRootView: View { @Environment(FlixySession.self) private var session + @Environment(PlaybackCoordinator.self) private var playback var body: some View { ZStack { @@ -38,6 +39,20 @@ struct TVRootView: View { } } .task { await session.restore() } + // **Hier und nicht in der Startseite.** Von einer Kachel, aus dem + // Aufmacher und aus der Detailseite wird dasselbe gestartet; läge der + // Übergang weiter innen, gäbe es ihn dreimal. + // + // `fullScreenCover(item:)` gibt es auf tvOS seit 14, und + // `PlaybackRequest` ist bereits `Identifiable`. + .fullScreenCover( + item: Binding( + get: { playback.request }, + set: { if $0 == nil { playback.close() } } + ) + ) { request in + TVPlayerScreen(request: request) + } } private func verbinden(mit name: String) -> some View { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift new file mode 100644 index 0000000..68a9382 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/TrackMenu.swift @@ -0,0 +1,99 @@ +import Foundation +import MediaKit + +/// Was in einem Spurenmenü steht — und was ein Umschalten kostet. +/// +/// **Warum es diesen Typ gibt.** Auf dem Fernseher sitzt die Spurwahl in +/// Apples Transportleiste, und die nimmt `UIMenu`-Einträge entgegen. Die +/// Abbildung von Spuren auf Einträge hat mit UIKit aber nichts zu tun: sie +/// beantwortet, welche Spuren zur Wahl stehen, welche gerade gilt, und — das +/// ist der Teil, der sonst nirgends steht — **ob das Umschalten Sekunden mit +/// schwarzem Bild kostet**. +/// +/// Diese Regel lag bisher nur mitten im Vollzug von +/// `PlayerModel.selectAudioStream` und war damit weder ablesbar noch prüfbar: +/// auslösen lässt sie sich nur an einem echten Server mit einer Datei, die +/// mehrere Tonspuren hat. Hier steht sie als Auskunft — und die Oberfläche +/// kann sie **vorher** anzeigen, statt den Nutzer hinterher vor ein schwarzes +/// Bild zu setzen. +/// +/// Liegt neben `AudioTrackChooser` und `DowngradeRule`, weil es dasselbe ist: +/// eine Regel über die Wiedergabe, ohne Oberfläche und ohne Netz. +public enum TrackMenu { + public struct Entry: Equatable, Sendable, Identifiable { + /// Der Index der Spur beim Server. `nil` heisst „Aus". + public let id: Int? + public let title: String + public let isSelected: Bool + /// Ob das Umschalten den Stream neu aufbaut. + public let rebuilds: Bool + + public init(id: Int?, title: String, isSelected: Bool, rebuilds: Bool) { + self.id = id + self.title = title + self.isSelected = isSelected + self.rebuilds = rebuilds + } + } + + /// Die Tonspuren **der Datei**, nicht die des Players. + /// + /// Rechnet der Server um, kennt der Player genau eine Spur — die, für die + /// er sich entschieden hat. Zur Wahl steht, was in der Datei liegt. + /// + /// - Parameter playerTrackCount: Wie viele Tonspuren der Player meldet. + /// Weicht die Zahl ab, stimmt die Zuordnung über die Reihenfolge nicht, + /// und es muss neu aufgebaut werden — lieber eine Unterbrechung als die + /// falsche Sprache. + public static func audio( + from streams: [MediaStream], + selected: MediaStream?, + isDirectPlay: Bool, + playerTrackCount: Int + ) -> [Entry] { + let billig = isDirectPlay && playerTrackCount == streams.count + return streams.enumerated().map { stelle, stream in + Entry( + id: stream.index ?? stelle, + title: stream.name, + isSelected: stream == selected, + rebuilds: !billig + ) + } + } + + /// Die Untertitelspuren, mit „Aus" an erster Stelle. + /// + /// - Parameter offLabel: Wie „Aus" in der Sprache des Nutzers heisst. + /// Als Argument, weil dieses Paket keinen Textkatalog der App kennt. + /// - Parameter playerTrackLanguages: Die Sprachkürzel der Spuren, die der + /// Player wirklich hat. Nur was sich darin abbilden lässt, schaltet ohne + /// Neuaufbau um — sonst stünde der falsche Untertitel im Bild, und das + /// ist schlimmer als eine kurze Unterbrechung. + public static func subtitles( + from streams: [MediaStream], + selected: MediaStream?, + isDirectPlay: Bool, + playerTrackLanguages: [String?] = [], + offLabel: String + ) -> [Entry] { + // „Aus" kostet nie einen Neuaufbau: abschalten kann der Player immer, + // auch eine Spur, die er nie hatte. + var einträge = [ + Entry(id: nil, title: offLabel, isSelected: selected == nil, rebuilds: false) + ] + + einträge += streams.enumerated().map { stelle, stream in + let abbildbar = isDirectPlay && playerTrackLanguages.contains { + LanguageCode.matches(stream.languageCode, $0) + } + return Entry( + id: stream.index ?? stelle, + title: stream.name, + isSelected: stream == selected, + rebuilds: !abbildbar + ) + } + return einträge + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index c836328..cc12c01 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -586,12 +586,18 @@ public final class AVPlayerEngine: VideoPlayerEngine { /// Die Tonsitzung des Systems. /// -/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen — +/// Auf iOS **und tvOS**. Gekapselt statt an drei Stellen mit `#if` versehen — /// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne /// Simulator laufen. +/// +/// **tvOS stand hier lange nicht**, und das war ein stiller Fehler: die +/// Kategorie wäre dort verfügbar (`AVAudioSessionCategoryPlayback` ist +/// `tvos(9.0)`), wurde aber nie gesetzt. Man hätte es nicht am Übersetzer +/// gemerkt, sondern erst daran, dass sich die Wiedergabe gegen anderen Ton +/// nicht durchsetzt. enum AudioSession { static func activate() { - #if os(iOS) + #if os(iOS) || os(tvOS) // Ohne diese Kategorie schweigt die Wiedergabe, sobald der // Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar // nicht. @@ -601,7 +607,7 @@ enum AudioSession { } static func deactivate() { - #if os(iOS) + #if os(iOS) || os(tvOS) // `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen, // die vor dem Film gespielt hat. try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift new file mode 100644 index 0000000..b96e363 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/TrackMenuTests.swift @@ -0,0 +1,115 @@ +import Testing +import MediaKit +@testable import FlixyPlayback + +@Suite("Was im Spurenmenü steht") +struct TrackMenuTests { + + private func spur( + _ id: String, + kind: MediaStream.Kind = .audio, + sprache: String? = "Deutsch", + code: String? = "deu", + name: String? = nil, + codec: String? = "AC3" + ) -> MediaStream { + MediaStream( + id: id, kind: kind, codec: codec, language: sprache, languageCode: code, + displayTitle: name, channels: nil, isSelected: false, isForced: false, + title: nil, isDefault: false, isHearingImpaired: false, isOriginal: false + ) + } + + // MARK: - Wann es einen Neuaufbau kostet + + /// **Der Fall, der bisher nirgends stand.** Rechnet der Server um, liegt + /// im Stream genau eine Tonspur — jede andere Wahl verlangt eine neue + /// Bestellung und damit Sekunden mit schwarzem Bild. + @Test("Beim Umrechnen kostet jede Wahl einen Neuaufbau") + func transcodeAlwaysRebuilds() { + let einträge = TrackMenu.audio( + from: [spur("1"), spur("2")], + selected: nil, isDirectPlay: false, playerTrackCount: 1 + ) + #expect(einträge.allSatisfy { $0.rebuilds }) + } + + @Test("Unverändert gespielt und gleiche Anzahl: kein Neuaufbau") + func directPlayMatchingCountIsCheap() { + let einträge = TrackMenu.audio( + from: [spur("1"), spur("2")], + selected: nil, isDirectPlay: true, playerTrackCount: 2 + ) + #expect(einträge.allSatisfy { !$0.rebuilds }) + } + + /// **Die Regel aus `PlayerModel`, zum ersten Mal prüfbar.** Die Zuordnung + /// geht über die Reihenfolge; stimmt die Anzahl nicht, ist die Annahme + /// falsch, und es wäre die falsche Sprache statt einer Unterbrechung. + /// Auslösen liess sich das bisher nur an einem echten Server. + @Test("Abweichende Anzahl heißt Neuaufbau, auch unverändert gespielt") + func directPlayMismatchRebuilds() { + let einträge = TrackMenu.audio( + from: [spur("1"), spur("2"), spur("3")], + selected: nil, isDirectPlay: true, playerTrackCount: 2 + ) + #expect(einträge.allSatisfy { $0.rebuilds }) + } + + // MARK: - Untertitel + + @Test("„Aus“ steht vorn und kostet nie einen Neuaufbau") + func offComesFirstAndIsFree() { + let einträge = TrackMenu.subtitles( + from: [spur("1", kind: .subtitle)], + selected: nil, isDirectPlay: false, offLabel: "Aus" + ) + #expect(einträge.first?.id == nil) + #expect(einträge.first?.title == "Aus") + #expect(einträge.first?.isSelected == true) + #expect(einträge.first?.rebuilds == false) + } + + /// Nur was der Player wirklich hat, lässt sich ohne Neuaufbau schalten. + @Test("Eine Untertitelspur, die der Player nicht hat, kostet einen Neuaufbau") + func unmappableSubtitleRebuilds() { + let deutsch = spur("1", kind: .subtitle, sprache: "Deutsch", code: "deu") + let englisch = spur("2", kind: .subtitle, sprache: "English", code: "eng") + let einträge = TrackMenu.subtitles( + from: [deutsch, englisch], + selected: nil, isDirectPlay: true, + playerTrackLanguages: ["de"], offLabel: "Aus" + ) + // „deu" und „de" sind dieselbe Sprache — das entscheidet LanguageCode. + #expect(einträge[1].rebuilds == false) + #expect(einträge[2].rebuilds == true) + } + + // MARK: - Beschriftung + + @Test("Der Name des Servers gewinnt, sonst Sprache und Codec") + func fallsBackToLanguageAndCodec() { + let mitNamen = spur("1", name: "Deutsch (AC3 5.1)") + let ohneNamen = spur("2", sprache: "English", codec: "eac3") + let einträge = TrackMenu.audio( + from: [mitNamen, ohneNamen], + selected: mitNamen, isDirectPlay: true, playerTrackCount: 2 + ) + #expect(einträge[0].title == "Deutsch (AC3 5.1)") + #expect(einträge[1].title == "English · EAC3") + #expect(einträge[0].isSelected) + #expect(!einträge[1].isSelected) + } + + /// Ohne alles bleibt ein Strich — besser als eine leere Zeile, bei der + /// niemand weiss, ob sie anklickbar ist. + @Test("Ohne Sprache und Codec bleibt ein Strich") + func namelessStreamStillHasALabel() { + let leer = spur("7", sprache: nil, code: nil, codec: nil) + let einträge = TrackMenu.audio( + from: [leer], selected: nil, isDirectPlay: true, playerTrackCount: 1 + ) + #expect(einträge[0].title == "—") + #expect(einträge[0].id == 7) + } +} diff --git a/Packages/FlixyVLC/Package.resolved b/Packages/FlixyVLC/Package.resolved index c362a13..613e4ba 100644 --- a/Packages/FlixyVLC/Package.resolved +++ b/Packages/FlixyVLC/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b573b58f837dec84b5981e792f7548e0bb126412c181987af9b265133a652e02", + "originHash" : "8b778ccd4dce9d26be9db57d4587b19c4a4366fcdaf8b6017e1df36fa1eddb4f", "pins" : [ { "identity" : "vlckit", diff --git a/project.yml b/project.yml index a09d1b7..0cc7f17 100644 --- a/project.yml +++ b/project.yml @@ -175,16 +175,26 @@ targets: excludes: # Ein Fernseher dreht sich nicht. - "OrientationLock.swift" - # Downloads gibt es hier nicht. - - "DownloadService.swift" - - "DownloadRefiller.swift" - # Kommt mit dem Player in Schritt sieben. Solange es ihn nicht gibt, - # hätte die Anmeldung beim System nichts anzumelden. - - "NowPlaying.swift" + # Heruntergeladen wird auf dem Fernseher nichts. + # + # `DownloadRefiller.swift` und `DownloadService.swift` kommen + # trotzdem mit: `PlayerModel` nennt den Nachfüller in einem Zweig, + # der auf tvOS nie läuft (`downloads` ist dort `nil`), und der + # Nachfüller wiederum den Dienst. Sie auszuschliessen hiesse, eine + # Plattformfrage mitten in zweitausend Zeilen Wiedergabelogik zu + # tragen — teurer als zwei Dateien mitzuübersetzen, die niemand + # aufruft. Beide sind reine Übersetzer ohne UIKit. # Reines Modell trotz des Ordnernamens: LoadState, ErrorText und # MediaRoute liegen zusammen in einer Datei. - path: Flixy/Views/LoadState.swift type: file + # Der Player. **Ohne PlayerView** — die zeichnet auf iOS das Bild und + # alle Bedienelemente selbst; auf dem Fernseher macht das Apples + # AVPlayerViewController. Das Modell darunter ist dasselbe. + - path: Flixy/Views/Player/PlayerModel.swift + type: file + - path: Flixy/Views/Player/VideoSurface.swift + type: file - path: Flixy/Branding.swift type: file # Die Texte. Dieselben Schlüssel, dieselben Übersetzungen. @@ -206,6 +216,11 @@ targets: product: FlixyDesign - package: FlixyKit product: FlixyPlayback + # Nur damit `PlayerModel` den Typ nennen darf. Angelegt wird nie einer: + # FlixyTVApp reicht `nil` durch, und damit gibt es auf tvOS auch keinen + # ModelContainer und kein SwiftData. + - package: FlixyKit + product: FlixyDownloads settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.tv