diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 3dcdb0e..86e6ead 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2566,6 +2566,7 @@ } }, "player.stalled": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { @@ -2665,6 +2666,38 @@ } } }, + "quality.audioOnly": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur Ton" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Audio only" + } + } + } + }, + "quality.audioOnly.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it." + } + } + } + }, "quality.automatic": { "extractionState": "stale", "localizations": { @@ -2682,36 +2715,82 @@ } } }, - "quality.fullHD": { - "extractionState": "stale", + "quality.current": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Full HD · 1080p" + "value": "Läuft gerade" } }, "en": { "stringUnit": { "state": "translated", - "value": "Full HD · 1080p" + "value": "Currently playing" } } } }, - "quality.hd": { - "extractionState": "stale", + "quality.current.bitrate": { "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "HD · 720p" + "value": "Bitrate" } }, "en": { "stringUnit": { "state": "translated", - "value": "HD · 720p" + "value": "Bitrate" + } + } + } + }, + "quality.current.dropped": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verworfene Bilder" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dropped frames" + } + } + } + }, + "quality.current.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gemessen, nicht gewünscht: die Stufe sagt, was erlaubt ist — hier steht, was ankommt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Measured, not requested: the setting caps the rate, this is what arrives." + } + } + } + }, + "quality.current.resolution": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auflösung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resolution" } } } @@ -2733,6 +2812,23 @@ } } }, + "quality.kilobits %lld": { + "comment": "Platzhalter: Kilobit je Sekunde", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld kbps" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld kbps" + } + } + } + }, "quality.low": { "extractionState": "stale", "localizations": { @@ -2750,23 +2846,6 @@ } } }, - "quality.lowRes": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sparsam · 480p" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Data saver · 480p" - } - } - } - }, "quality.medium": { "extractionState": "stale", "localizations": { @@ -2784,6 +2863,23 @@ } } }, + "quality.megabits %@": { + "comment": "Platzhalter: Megabit je Sekunde", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ Mbps" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ Mbps" + } + } + } + }, "quality.minimal": { "extractionState": "stale", "localizations": { diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift index 38c272b..5b6b352 100644 --- a/Flixy/Session/PlaybackCoordinator.swift +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -53,7 +53,7 @@ final class PlaybackCoordinator { /// Der Unterschied zwischen „klein" und „gar nicht" ist der Kern der /// Sache: verkleinern darf die Wiedergabe **nicht** anhalten. Vorher gab /// es diese Unterscheidung nicht — der Player war da oder weg, und mit - /// ihm der Motor, der Transcoder und beim Live-TV der belegte Tuner. + /// ihm die Engine, der Transcoder und beim Live-TV der belegte Tuner. enum Presentation: Equatable { case hidden case mini @@ -120,7 +120,7 @@ final class PlaybackCoordinator { /// Beendet die Wiedergabe wirklich. /// /// Die einzige Stelle, an der das passiert. Alles andere — verkleinern, - /// vergrößern, Bild-in-Bild — lässt den Motor am Leben. + /// vergrößern, Bild-in-Bild — lässt die Engine am Leben. func close() { request = nil presentation = .hidden diff --git a/Flixy/Session/PlaybackDiagnostics.swift b/Flixy/Session/PlaybackDiagnostics.swift index 40026cb..2d32363 100644 --- a/Flixy/Session/PlaybackDiagnostics.swift +++ b/Flixy/Session/PlaybackDiagnostics.swift @@ -51,7 +51,7 @@ final class PlaybackDiagnostics { /// /// Der Abstand ist die eigentliche Auskunft: steht zwischen „Quelle /// gewählt" und „spielt" eine Minute, liegt es an der Leitung; kommt - /// „spielt" gar nicht, am Motor. + /// „spielt" gar nicht, am Engine. func note(_ step: String) { guard var attempt = lastAttempt else { return } let elapsed = Date().timeIntervalSince(attempt.startedAt) diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 1aae681..de11248 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -226,7 +226,7 @@ struct LiveTVView: View { defer { isTuning = nil } // Die laufende Sendung mitgeben: über ihre Adresse beim - // Programmanbieter führt der direkte Weg zum Strom. + // Programmanbieter führt der direkte Weg zum Stream. let running = program ?? programs[channel.id]?.first { $0.isRunning() } let policy = settings.policy(for: network) diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 1487f0d..305cc08 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -107,8 +107,8 @@ struct RootView: View { /// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon /// abgestimmt, bevor es hier ankommt. private func handOver(to request: PlaybackRequest?) async { - // Zuerst und ohne Warten: den alten Motor stilllegen. Das Aufräumen am - // Server darf dauern, das Abschalten nicht — läuft der alte Motor + // Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am + // Server darf dauern, das Abschalten nicht — läuft der alte Engine // weiter, während der neue startet, streiten sich zwei um dieselbe // Audiositzung. Zu hören war das als alter Ton, der weiterlief, und // als neue Wiedergabe, die stumm blieb. diff --git a/Flixy/Views/Player/MiniPlayer.swift b/Flixy/Views/Player/MiniPlayer.swift index 66bee26..c1149d7 100644 --- a/Flixy/Views/Player/MiniPlayer.swift +++ b/Flixy/Views/Player/MiniPlayer.swift @@ -17,23 +17,26 @@ struct MiniPlayer: View { @Environment(PlaybackCoordinator.self) private var playback @Environment(\.horizontalSizeClass) private var sizeClass - /// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal - /// nach links unten gezogen hat, will sie beim nächsten Mal dort haben. - @AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue + /// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel + /// einmal nach links unten gezogen hat, will sie dort wiederfinden. + @AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue + /// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben + /// Stelle, statt unten herauszufallen. + @AppStorage("miniPlayer.height") private var storedFraction = 0.85 /// Verschiebung während des Ziehens, noch nicht eingerastet. @State private var drag: CGSize = .zero @State private var showsControls = false @State private var hideTask: Task? - private var corner: MiniPlayerPlacement.Corner { - MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing + private var side: MiniPlayerPlacement.Side { + MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing } /// Die selbst eingestellte Breite. Null heißt „noch nie angefasst". @AppStorage("miniPlayer.width") private var storedWidth: Double = 0 - /// Änderung während des Ziehens am Griff, noch nicht übernommen. - @State private var widthChange: CGFloat = 0 + /// Vergrößerung während des Zusammen- oder Auseinanderziehens. + @State private var zoom: CGFloat = 1 /// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt. /// /// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und @@ -49,7 +52,7 @@ struct MiniPlayer: View { // Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter // ist als das Fenster, ließe sich nicht mehr wegziehen. let upper = max(160, min(bounds.width * 0.75, 560)) - let width = min(max(chosen + widthChange, 140), upper) + let width = min(max(chosen * zoom, 140), upper) return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded()) } @@ -57,18 +60,21 @@ struct MiniPlayer: View { GeometryReader { geometry in let tileSize = tileSize(in: geometry.size) let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize) - let resting = placement.center(for: corner) + let resting = placement.center(side: side, verticalFraction: storedFraction) let position = placement.clamped( center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height) ) - tile(corner: corner) + tile .frame(width: tileSize.width, height: tileSize.height) .onChange(of: tileSize.width, initial: true) { _, width in lastWidth = width } .position(position) .gesture(dragGesture(in: placement, from: resting)) + // Gleichzeitig, damit das Zusammenziehen nicht erst nach dem + // Loslassen greift — beides passiert mit denselben Fingern. + .simultaneousGesture(zoomGesture(in: geometry.size)) // Nur während des Ziehens ohne Animation, sonst würde die // Kachel dem Finger hinterherfedern statt an ihm zu kleben. .animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position) @@ -76,7 +82,7 @@ struct MiniPlayer: View { .transition(.scale(scale: 0.8).combined(with: .opacity)) } - private func tile(corner: MiniPlayerPlacement.Corner) -> some View { + private var tile: some View { ZStack { Color.black @@ -88,7 +94,6 @@ struct MiniPlayer: View { if showsControls { controls.transition(.opacity) - resizeHandle(corner: corner).transition(.opacity) } // Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung — @@ -156,58 +161,22 @@ struct MiniPlayer: View { } } - /// Der Griff zum Größerziehen. + /// Größer und kleiner durch Zusammen- und Auseinanderziehen. /// - /// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er - /// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand - /// läge er halb außerhalb des Daumens. - private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View { - // Über `overlay(alignment:)` gesetzt, nicht über gedrehte Stapel: - // eine Drehung auf dem Überzug kippt die gesamte Trefferfläche mit, - // und der Griff lag dann dort, wo man nicht hinfasst. Genau daran war - // das Ziehen vorher wirkungslos. - Color.clear.overlay(alignment: handleAlignment(for: corner)) { - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.caption.weight(.bold)) - .foregroundStyle(.white) - // Nur das Symbol dreht sich, damit der Pfeil in die Richtung - // zeigt, in die es tatsächlich größer wird. - .rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90)) - .frame(width: 44, height: 44) - .contentShape(.rect) - // Vorrang vor dem Verschieben der Kachel, das am selben Ort - // hängt — sonst gewinnt das Ziehen und die Kachel wandert. - .highPriorityGesture(resizeGesture(corner: corner)) - // Und ein Tippen darf hier nicht ins Vollbild führen. - .onTapGesture { reveal() } - .accessibilityLabel(Text("miniPlayer.resize")) - } - } - - /// Die Ecke der Kachel, die zur Bildschirmmitte zeigt. - private func handleAlignment(for corner: MiniPlayerPlacement.Corner) -> Alignment { - switch corner { - case .topLeading: .bottomTrailing - case .topTrailing: .bottomLeading - case .bottomLeading: .topTrailing - case .bottomTrailing: .topLeading - } - } - - private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture { - DragGesture(minimumDistance: 2) + /// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein + /// und stritt sich mit dem Verschieben um dieselbe Geste. + private func zoomGesture(in bounds: CGSize) -> some Gesture { + MagnifyGesture() .onChanged { value in hideTask?.cancel() - // Klebt die Kachel rechts, wächst sie beim Ziehen nach links. - widthChange = corner.isLeading ? value.translation.width : -value.translation.width + zoom = value.magnification } .onEnded { _ in // Die **begrenzte** Breite merken, nicht die gezogene: eine - // einmal zu weit gezogene Kachel bliebe sonst für immer zu + // einmal zu weit aufgezogene Kachel bliebe sonst für immer zu // groß, auch auf einem kleineren Bildschirm. storedWidth = Double(lastWidth) - widthChange = 0 - reveal() + zoom = 1 } } @@ -227,13 +196,16 @@ struct MiniPlayer: View { } .onEnded { value in // Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach - // dem Finger: sonst springt eine Kachel, die man an der Kante - // angefasst hat, in die falsche Hälfte. - let center = CGPoint( + // dem Finger: sonst springt eine Kachel, die man am Rand + // angefasst hat, auf die falsche Seite. + let center = placement.clamped(center: CGPoint( x: resting.x + value.predictedEndTranslation.width, y: resting.y + value.predictedEndTranslation.height - ) - storedCorner = placement.nearestCorner(toCenter: center).rawValue + )) + // Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie, + // wohin man sie gezogen hat. + storedSide = placement.nearestSide(toCenter: center).rawValue + storedFraction = placement.verticalFraction(forCenter: center) drag = .zero } } diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index eef4825..740bc88 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -9,8 +9,8 @@ import PlexKit /// Führt einen Abspielwunsch aus. /// /// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den -/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht -/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die +/// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht +/// weiß nicht, welche Engine läuft — bis auf die eine Stelle, an der die /// Bildfläche gebaut wird. @MainActor @Observable @@ -58,6 +58,17 @@ final class PlayerModel { /// Ob gerade auf eine andere Stufe umgestellt wird. private(set) var isChangingQuality = false + /// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt. + var statistics: PlaybackStatistics? { engine?.statistics } + + /// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt + /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein + /// Bild dazu. + var sourceHeight: Int? { currentMedia?.height } + + /// Ob das Bild gezeigt werden soll. + var showsVideo: Bool { quality.showsVideo } + /// Die Bildebene des Systemplayers. /// /// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel @@ -181,7 +192,7 @@ final class PlayerModel { /// Stellt auf eine andere Qualitätsstufe um. /// /// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse - /// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe. + /// des Streams, nicht in einer Einstellung, die sich unterwegs drehen ließe. /// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der /// Film bei jedem Wechsel von vorn. func select(quality newQuality: PlayerQuality) async { @@ -196,7 +207,7 @@ final class PlayerModel { quality = newQuality // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet - // der Server zwei Ströme gleichzeitig für einen Zuschauer. + // der Server zwei Streams gleichzeitig für einen Zuschauer. if let transcodeSession { await server.stopTranscode(sessionIdentifier: transcodeSession) self.transcodeSession = nil @@ -228,7 +239,7 @@ final class PlayerModel { /// Wiedergabe aus dem Download-Ordner. /// /// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf - /// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim + /// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim /// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen. /// Live-TV: die Adresse steht schon fest. /// @@ -236,7 +247,7 @@ final class PlayerModel { /// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es /// gibt keine Stelle, an der man ihn später fortsetzen könnte. private func startLiveStream(url: URL) async { - diagnostics.note("Live-Strom öffnen") + diagnostics.note("Live-Stream öffnen") isLive = true title = request.title // Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es @@ -309,11 +320,11 @@ final class PlayerModel { observe(engine) do { - diagnostics.note("Motor öffnet Quelle") + diagnostics.note("Engine öffnet Quelle") try await engine.load(source) engine.play() phase = .playing - diagnostics.note("Motor gestartet") + diagnostics.note("Engine gestartet") // Weder offline noch live gibt es einen Fortschritt zu melden. if !isOffline, !isLive { startReporting() } } catch { @@ -322,7 +333,7 @@ final class PlayerModel { } } - /// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom. + /// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream. private func source( for decision: PlaybackDecision, item: PlexItem, @@ -366,7 +377,7 @@ final class PlayerModel { /// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel. /// - /// Ein Live-Strom kann sterben, ohne dass der Motor es merkt — der Tuner + /// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner /// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten, /// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum /// nächsten Antippen, und niemand weiß, ob noch etwas kommt. @@ -379,8 +390,8 @@ final class PlayerModel { guard !Task.isCancelled, let self, self.isBuffering else { return } diagnostics.note("Seit 40 s keine Daten — abgebrochen") - // Den Motor gleich stilllegen: er würde sonst weiter an einem - // toten Strom ziehen, und bei Live-TV bliebe der Tuner belegt. + // Den Engine gleich stilllegen: er würde sonst weiter an einem + // toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt. silence() phase = .failed(message: String(localized: "player.stalled")) Task { await self.releaseOnServer() } @@ -451,11 +462,11 @@ final class PlayerModel { selectedSubtitleTrack = track } - /// Legt den Motor **sofort** still. Kein Netzwerk, kein Warten. + /// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten. /// /// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher - /// stand der Motor erst nach drei Netzwerkaufrufen still. Wurde in der - /// Zwischenzeit etwas Neues gestartet, liefen zwei Motoren gleichzeitig, + /// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der + /// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig, /// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung /// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem /// die App im Hintergrund war —, und das Neue blieb stumm. @@ -574,10 +585,10 @@ final class PlayerModel { return item.media[request.mediaIndex] } - /// Hängt die gehaltene Bildebene an den aktuellen Motor. + /// Hängt die gehaltene Bildebene an den aktuellen Engine. /// - /// Nach **jedem** Motorwechsel nötig — also auch nach einem - /// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene + /// Nach **jedem** Engine-Wechsel nötig — also auch nach einem + /// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene /// weiter auf einen abgeräumten Player und bleibt schwarz. private func attachSurface(to engine: any VideoPlayerEngine) { guard let avEngine = engine as? AVPlayerEngine else { return } @@ -628,14 +639,14 @@ final class PlayerModel { isBuffering = state == .buffering || state == .loading watchBuffering(isBuffering) // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist - // abzulesen, ob der Motor hängt oder die Leitung. + // abzulesen, ob die Engine hängt oder die Leitung. diagnostics.note("Zustand: \(state)") if case .failed(let failure) = state { diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)") // Der Grund gehört auf den Bildschirm, nicht nur ins // Protokoll: „die Wiedergabe wurde unterbrochen" allein // ist eine Sackgasse — es sagt weder, ob der Server - // ablehnt, noch ob der Motor das Format nicht mag. + // ablehnt, noch ob die Engine das Format nicht mag. phase = .failed(message: [ String(localized: "player.playbackFailed"), String(describing: failure), diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index d5a402c..ca17254 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -27,14 +27,21 @@ struct PlayerView: View { @State private var showsQualityChoice = false @State private var hideTask: Task? @State private var scrubbing: Double? + /// Welche Richtung gerade als Hinweis eingeblendet ist. + @State private var seekHint: Double? + @State private var hintTask: Task? var body: some View { ZStack { Color.black.ignoresSafeArea() Group { - VideoSurface(engine: model.engine, surface: model.surface) - .ignoresSafeArea() + // Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe + // Zweck der Stufe. + if model.showsVideo { + VideoSurface(engine: model.engine, surface: model.surface) + .ignoresSafeArea() + } switch model.phase { case .preparing: @@ -83,21 +90,20 @@ struct PlayerView: View { .persistentSystemOverlays(showsControls ? .automatic : .hidden) .landscapeWhileVisible() .contentShape(.rect) - .onTapGesture { toggleControls() } + // Zwei Hälften: links zurück, rechts vor. Ein einzelnes Tippen zeigt + // weiterhin die Bedienelemente — die Reihenfolge entscheidet, sonst + // verschluckt das einfache Tippen das doppelte. + .overlay { + HStack(spacing: 0) { + seekZone(seconds: -10, symbol: "gobackward.10") + seekZone(seconds: 10, symbol: "goforward.10") + } + } .sheet(isPresented: $showsTrackPicker) { TrackPickerSheet(model: model) } - .confirmationDialog( - Text("player.quality"), - isPresented: $showsQualityChoice, - titleVisibility: .visible - ) { - ForEach(PlayerQuality.allCases) { option in - Button(qualityChoiceLabel(option, current: model.quality)) { - Task { await model.select(quality: option) } - } - } - Button("action.cancel", role: .cancel) {} + .sheet(isPresented: $showsQualityChoice) { + QualitySheet(model: model) } .onChange(of: showsQualityChoice) { _, isShown in // Nach dem Schließen die Bedienelemente stehen lassen und die @@ -364,13 +370,45 @@ struct PlayerView: View { .padding(FlixySpacing.section) } - /// Die gewählte Stufe bekommt einen Haken. + /// Eine Bildschirmhälfte zum Springen. /// - /// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt - /// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein. - private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String { - let name = String(localized: option.localizedName) - return option == current ? "\(name) ✓" : name + /// Bewusst ohne sichtbare Trennung: die Fläche soll sich nicht aufdrängen. + /// Der kurze Hinweis beim Doppeltippen sagt genug. + private func seekZone(seconds: Double, symbol: String) -> some View { + Color.clear + .contentShape(.rect) + .overlay { + if seekHint == seconds { + Label( + seconds < 0 ? "player.back10" : "player.forward10", + systemImage: symbol + ) + .labelStyle(.iconOnly) + .font(.system(size: 44)) + .foregroundStyle(.white) + .padding(FlixySpacing.section) + .background(.black.opacity(0.35), in: .circle) + .transition(.opacity.combined(with: .scale)) + } + } + // Das doppelte zuerst: sonst gewinnt das einfache und man käme nie + // zum Springen. + .onTapGesture(count: 2) { seek(by: seconds) } + .onTapGesture { toggleControls() } + } + + private func seek(by seconds: Double) { + guard let current = model.duration != nil || model.isLive ? model.currentTime : nil + else { return } + model.seek(to: current + .seconds(seconds)) + + withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds } + hintTask?.cancel() + hintTask = Task { + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.2)) { seekHint = nil } + } } private func isPlayingPhase(_ model: PlayerModel) -> Bool { @@ -416,15 +454,3 @@ extension Duration { return String(format: "%d:%02d", minutes, seconds) } } - -extension PlayerQuality { - var localizedName: String.LocalizationValue { - switch self { - case .automatic: "quality.automatic" - case .original: "quality.original" - case .fullHD: "quality.fullHD" - case .hd: "quality.hd" - case .low: "quality.lowRes" - } - } -} diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift new file mode 100644 index 0000000..5928602 --- /dev/null +++ b/Flixy/Views/Player/QualitySheet.swift @@ -0,0 +1,137 @@ +import SwiftUI +import FlixyDesign +import FlixyPlayback + +/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft. +/// +/// Ein Blatt statt eines Dialogs: die Leiter hat je nach Quelle bis zu +/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen +/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben, +/// was gerade über die Leitung kommt. +struct QualitySheet: View { + let model: PlayerModel + + @Environment(\.flixyAccent) private var accent + @Environment(\.dismiss) private var dismiss + + private var options: [PlayerQuality] { + PlayerQuality.available(forSourceHeight: model.sourceHeight) + } + + var body: some View { + NavigationStack { + List { + current + + Section { + row(for: .automatic) + row(for: .original) + } + + ForEach(groupedHeights, id: \.self) { height in + Section("\(height)p") { + ForEach(rungs(height: height), id: \.self) { rung in + row(for: .limited(rung)) + } + } + } + + Section { + row(for: .audioOnly) + } footer: { + Text("quality.audioOnly.hint") + } + } + .navigationTitle(Text("player.quality")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("action.done") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + } + + /// Was gerade läuft — gemessen, nicht gewünscht. + @ViewBuilder + private var current: some View { + if let statistics = model.statistics { + Section { + if let observed = statistics.observedBitrate { + LabeledContent("quality.current.bitrate", value: bitrateText(observed)) + } + if let resolution = statistics.resolution { + LabeledContent("quality.current.resolution", value: resolution) + } + if let dropped = statistics.droppedFrames, dropped > 0 { + LabeledContent("quality.current.dropped", value: "\(dropped)") + } + } header: { + Text("quality.current") + } footer: { + // Der Unterschied ist keine Haarspalterei: die Obergrenze sagt, + // was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es + // trotz hoher Stufe, steht die Antwort hier. + Text("quality.current.hint") + } + } + } + + private var groupedHeights: [Int] { + var seen = Set() + return options.compactMap { option -> Int? in + guard case .limited(let rung) = option else { return nil } + return seen.insert(rung.height).inserted ? rung.height : nil + } + } + + private func rungs(height: Int) -> [PlayerQuality.Rung] { + options.compactMap { option in + guard case .limited(let rung) = option, rung.height == height else { return nil } + return rung + } + } + + private func row(for option: PlayerQuality) -> some View { + Button { + Task { await model.select(quality: option) } + dismiss() + } label: { + HStack { + Text(label(for: option)) + .foregroundStyle(Color.flixyTextPrimary) + Spacer() + if option == model.quality { + Image(systemName: "checkmark").foregroundStyle(accent) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + private func label(for option: PlayerQuality) -> String { + switch option { + case .automatic: String(localized: "quality.automatic") + case .original: String(localized: "quality.original") + case .audioOnly: String(localized: "quality.audioOnly") + case .limited(let rung): bitrateText(rung.bitrate) + } + } + + /// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle. + /// + /// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps" + /// schlechter als „64 kbps". + private func bitrateText(_ kilobits: Int) -> String { + guard kilobits >= 1_000 else { + return String(localized: "quality.kilobits \(kilobits)") + } + let megabits = Double(kilobits) / 1_000 + let rounded = megabits.rounded() == megabits + ? String(Int(megabits)) + : String(format: "%.1f", megabits) + return String(localized: "quality.megabits \(rounded)") + } +} diff --git a/Flixy/Views/Player/VideoSurface.swift b/Flixy/Views/Player/VideoSurface.swift index db40200..bbf8e09 100644 --- a/Flixy/Views/Player/VideoSurface.swift +++ b/Flixy/Views/Player/VideoSurface.swift @@ -3,10 +3,10 @@ import SwiftUI import FlixyPlayback import FlixyVLC -/// Die Bildfläche des laufenden Motors. +/// Die Bildfläche des laufendie Engine. /// /// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden -/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise +/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise /// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche /// `UIView`. struct VideoSurface: View { diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift index 5b5218d..a809caa 100644 --- a/Flixy/Views/Settings/DiagnosticsView.swift +++ b/Flixy/Views/Settings/DiagnosticsView.swift @@ -4,7 +4,7 @@ import PlexKit /// Was beim letzten Abspielversuch passiert ist. /// -/// Gedacht als Antwort auf „es läuft nicht": hier steht, welcher Motor gewählt +/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt /// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat. /// Ein Screenshot davon sagt mehr als jede Beschreibung. struct DiagnosticsView: View { diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 9da016d..8166de7 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -40,7 +40,7 @@ let package = Package( .target(name: "FlixyDesign", resources: [.process("Localizable.xcstrings")]), .testTarget(name: "FlixyDesignTests", dependencies: ["FlixyDesign"]), - // Die Wiedergabe-Abstraktion samt AVPlayer-Motor und der Entscheidung + // Die Wiedergabe-Abstraktion samt AVPlayer-Engine und der Entscheidung // DirectPlay/Transcode. VLCKit steckt bewusst NICHT hier drin, sondern // in FlixyPlaybackVLC — sonst zöge jeder Testlauf ein 920 MB großes // xcframework hinter sich her. diff --git a/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift index 9b3b844..44dc35a 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/MiniPlayerPlacement.swift @@ -3,17 +3,18 @@ import CoreGraphics /// Wo die schwebende Videokachel sitzt. /// +/// Sie klebt an der linken oder rechten Seite und ist dort **frei in der +/// Höhe** — nicht auf vier Ecken festgelegt. Vier Ecken sind schnell gebaut, +/// aber im Weg: steht die Kachel unten rechts über einem Knopf, bleibt nur +/// die Wahl zwischen vier Orten, von denen drei ebenso stören. +/// /// Eigener Typ statt Rechnungen in der Ansicht, aus demselben Grund wie bei /// `GuideLayout`: hier geht lautlos schief, was man nicht sieht. Eine Kachel, -/// die halb aus dem Bild ragt, oder eine, die beim Loslassen in die falsche -/// Ecke springt, fällt erst auf dem Gerät auf — und dann als Gefühl, nicht -/// als Fehler. +/// die halb aus dem Bild ragt, fällt erst auf dem Gerät auf — und dann als +/// Gefühl, nicht als Fehler. public struct MiniPlayerPlacement: Sendable, Equatable { - public enum Corner: String, Sendable, CaseIterable, Codable { - case topLeading, topTrailing, bottomLeading, bottomTrailing - - public var isTop: Bool { self == .topLeading || self == .topTrailing } - public var isLeading: Bool { self == .topLeading || self == .bottomLeading } + public enum Side: String, Sendable, CaseIterable, Codable { + case leading, trailing } /// Der sichtbare Bereich, in dem die Kachel sich bewegen darf. @@ -29,56 +30,52 @@ public struct MiniPlayerPlacement: Sendable, Equatable { self.inset = inset } - /// Die Ecke, in die eine losgelassene Kachel gehört. + private var halfWidth: CGFloat { tile.width / 2 } + private var halfHeight: CGFloat { tile.height / 2 } + + private var minimumX: CGFloat { inset + halfWidth } + private var maximumX: CGFloat { max(minimumX, bounds.width - inset - halfWidth) } + private var minimumY: CGFloat { inset + halfHeight } + private var maximumY: CGFloat { max(minimumY, bounds.height - inset - halfHeight) } + + /// An welche Seite eine losgelassene Kachel gehört. /// /// Entschieden wird nach dem **Mittelpunkt** der Kachel, nicht nach dem - /// Finger: sonst springt eine Kachel, die man an der oberen Kante - /// angefasst hat, in die falsche Hälfte. - public func nearestCorner(toCenter center: CGPoint) -> Corner { - let isLeading = center.x < bounds.width / 2 - let isTop = center.y < bounds.height / 2 - - switch (isTop, isLeading) { - case (true, true): return .topLeading - case (true, false): return .topTrailing - case (false, true): return .bottomLeading - case (false, false): return .bottomTrailing - } + /// Finger: sonst springt eine Kachel, die man am Rand angefasst hat, auf + /// die falsche Seite. + public func nearestSide(toCenter center: CGPoint) -> Side { + center.x < bounds.width / 2 ? .leading : .trailing } - /// Die linke obere Ecke der Kachel für eine Ecke des Bildschirms. - public func origin(for corner: Corner) -> CGPoint { - CGPoint( - x: corner.isLeading ? inset : max(inset, bounds.width - tile.width - inset), - y: corner.isTop ? inset : max(inset, bounds.height - tile.height - inset) + /// Wie weit unten die Kachel sitzt: 0 ganz oben, 1 ganz unten. + /// + /// Als Anteil statt als Punktwert, damit die Kachel beim Drehen des Geräts + /// an derselben *relativen* Stelle bleibt, statt unten herauszufallen. + public func verticalFraction(forCenter center: CGPoint) -> Double { + let travel = maximumY - minimumY + guard travel > 0 else { return 0 } + return min(max(Double((center.y - minimumY) / travel), 0), 1) + } + + public func center(side: Side, verticalFraction: Double) -> CGPoint { + let fraction = min(max(verticalFraction, 0), 1) + return CGPoint( + x: side == .leading ? minimumX : maximumX, + y: minimumY + (maximumY - minimumY) * CGFloat(fraction) ) } - /// Der Mittelpunkt der Kachel in einer Ecke — damit rechnet SwiftUI. - public func center(for corner: Corner) -> CGPoint { - let start = origin(for: corner) - return CGPoint(x: start.x + tile.width / 2, y: start.y + tile.height / 2) - } - /// Hält einen Mittelpunkt im sichtbaren Bereich. /// /// Während des Ziehens, damit die Kachel nicht hinter den Rand rutscht und /// dort unerreichbar wird — der Finger wäre dann schneller als das Ziel. public func clamped(center: CGPoint) -> CGPoint { - let halfWidth = tile.width / 2 - let halfHeight = tile.height / 2 - - let minX = inset + halfWidth - let maxX = bounds.width - inset - halfWidth - let minY = inset + halfHeight - let maxY = bounds.height - inset - halfHeight - - return CGPoint( + CGPoint( // `max(min…)` in dieser Reihenfolge, weil bei einem Bereich, der // schmaler ist als die Kachel, sonst der untere Anschlag verliert // und die Kachel rechts hinausragt. - x: max(minX, min(maxX, center.x)), - y: max(minY, min(maxY, center.y)) + x: max(minimumX, min(maximumX, center.x)), + y: max(minimumY, min(maximumY, center.y)) ) } } diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift index 05fb066..afe93f1 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadItem.swift @@ -115,7 +115,7 @@ public final class DownloadItem { /// /// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich /// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs, -/// um zwischen den Motoren zu wählen. +/// um zwischen die Engines zu wählen. public struct OfflineMedia: Sendable, Codable, Equatable { public var container: String? public var videoCodec: String? diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift index 59f44aa..09e2430 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/DeviceCapabilities.swift @@ -2,7 +2,7 @@ import Foundation /// Was AVPlayer auf diesem Gerät nativ abspielen kann. /// -/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Motoren hat. iOS +/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Engines hat. iOS /// spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das /// liegt in typischen Plex-Beständen. Alles, was hier nicht steht, geht an /// VLCKit (oder ab Phase 2 an den Transcoder). diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift index 9bbfb2e..df9ae05 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift @@ -1,6 +1,6 @@ import Foundation -/// Welcher Motor die Wiedergabe übernimmt. +/// Welcher Engine die Wiedergabe übernimmt. public enum PlaybackEngineKind: String, Sendable, Equatable, Codable { /// Der Systemplayer. Bringt Bild-in-Bild, AirPlay und die Steuerung im /// Sperrbildschirm mit — deshalb hat er immer Vorrang, wenn er kann. diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift index 5c83646..0ad4402 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlayerQuality.swift @@ -5,36 +5,106 @@ import Foundation /// Getrennt von den Einstellungen unter „Qualität": dort steht, was /// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil /// es gerade ruckelt oder weil man ausnahmsweise das Original will. -public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable { +public enum PlayerQuality: Hashable, Sendable, Identifiable { /// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist. case automatic /// Die Datei unverändert, ohne Umrechnung. case original - case fullHD - case hd - case low + /// Eine Stufe der Leiter, die der Server anbietet. + case limited(Rung) + /// So wenig Bild wie möglich. + case audioOnly - public var id: String { rawValue } + /// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht. + /// + /// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server + /// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter + /// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht + /// kennt — er rundet dann still auf die nächste, und die Anzeige log. + public struct Rung: Hashable, Sendable { + public let bitrate: Int + public let height: Int + + public init(bitrate: Int, height: Int) { + self.bitrate = bitrate + self.height = height + } + + public var width: Int { + // 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag + // kein Kodierer. + let raw = Int((Double(height) * 16.0 / 9.0).rounded()) + return raw % 2 == 0 ? raw : raw + 1 + } + + public var resolution: String { "\(width)x\(height)" } + } + + /// Die Leiter von oben nach unten. + public static let ladder: [Rung] = [ + Rung(bitrate: 20_000, height: 1080), + Rung(bitrate: 12_000, height: 1080), + Rung(bitrate: 10_000, height: 1080), + Rung(bitrate: 8_000, height: 1080), + Rung(bitrate: 4_000, height: 1080), + Rung(bitrate: 3_000, height: 720), + Rung(bitrate: 2_000, height: 720), + Rung(bitrate: 1_500, height: 720), + Rung(bitrate: 720, height: 480), + Rung(bitrate: 320, height: 360), + Rung(bitrate: 208, height: 360), + Rung(bitrate: 96, height: 240), + Rung(bitrate: 64, height: 160), + ] + + public static var allCases: [PlayerQuality] { + [.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly] + } + + public var id: String { + switch self { + case .automatic: "automatic" + case .original: "original" + case .audioOnly: "audioOnly" + case .limited(let rung): "limited-\(rung.bitrate)" + } + } /// Obergrenze in Kilobit. `nil` bei `automatic` und `original`. public var maximumBitrate: Int? { switch self { case .automatic, .original: nil - case .fullHD: 8_000 - case .hd: 4_000 - case .low: 2_000 + case .limited(let rung): rung.bitrate + // Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse + // ist das, was dem am nächsten kommt; das Bild wird dazu im Player + // ausgeblendet, damit der Schirm dunkel bleiben kann. + case .audioOnly: Self.ladder.last?.bitrate } } public var resolution: String? { switch self { case .automatic, .original: nil - case .fullHD: "1920x1080" - case .hd: "1280x720" - case .low: "854x480" + case .limited(let rung): rung.resolution + case .audioOnly: Self.ladder.last?.resolution } } + /// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt. + public var height: Int? { + switch self { + case .automatic, .original: nil + case .limited(let rung): rung.height + case .audioOnly: Self.ladder.last?.height + } + } + + /// Ob das Bild gezeigt werden soll. + public var showsVideo: Bool { + if case .audioOnly = self { return false } + return true + } + /// Ob diese Stufe eine Umrechnung erzwingt. /// /// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die @@ -42,7 +112,7 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable { public var forcesTranscode: Bool { switch self { case .automatic, .original: false - case .fullHD, .hd, .low: true + case .limited, .audioOnly: true } } @@ -57,7 +127,7 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable { case .original: // Keine Grenze und kein Umrechnen: ausdrücklich das Original. PlaybackPolicy(allowsTranscoding: false, allowsFallbackEngine: true, maximumBitrate: nil) - case .fullHD, .hd, .low: + case .limited, .audioOnly: // Ohne Ersatzmotor, damit die Entscheidung zwangsläufig beim // Transcoder landet — sonst spielte VLC die Datei direkt ab und // die gewählte Stufe bliebe wirkungslos. @@ -68,4 +138,17 @@ public enum PlayerQuality: String, Sendable, CaseIterable, Identifiable { ) } } + + /// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind. + /// + /// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und + /// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen. + public static func available(forSourceHeight height: Int?) -> [PlayerQuality] { + guard let height, height > 0 else { return allCases } + let fitting = ladder.filter { $0.height <= height } + // Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer + // sehr kleinen Quelle nur „Original". + let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting + return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly] + } } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index 04264f0..e1089cf 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -136,6 +136,30 @@ public final class AVPlayerEngine: VideoPlayerEngine { select(track, in: .legible) } + /// Was gerade über die Leitung kommt. + /// + /// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede + /// gezogene Stufe. Der **letzte** Eintrag ist der aktuelle; die früheren + /// beschreiben, was vorher lief. + public var statistics: PlaybackStatistics? { + guard let item = player.currentItem else { return nil } + let event = item.accessLog()?.events.last + + func kilobits(_ value: Double?) -> Int? { + guard let value, value > 0 else { return nil } + return Int((value / 1_000).rounded()) + } + + let size = item.presentationSize + return PlaybackStatistics( + observedBitrate: kilobits(event?.observedBitrate), + indicatedBitrate: kilobits(event?.indicatedBitrate), + width: size.width > 0 ? Int(size.width) : nil, + height: size.height > 0 ? Int(size.height) : nil, + droppedFrames: event.map { max(0, $0.numberOfDroppedVideoFrames) } + ) + } + public func teardown() { teardownObservers() player.replaceCurrentItem(with: nil) @@ -192,7 +216,7 @@ public final class AVPlayerEngine: VideoPlayerEngine { // Kein `assumeIsolated`: KVO meldet auf dem Thread, der die // Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand // auch aus internen Threads. Genau dieselbe Annahme hat die App - // im VLC-Motor zum Absturz gebracht. + // im VLC-Engine zum Absturz gebracht. Task { @MainActor in guard let self else { return } switch player.timeControlStatus { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift index 4e6d8c5..b955d4e 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift @@ -5,7 +5,7 @@ import PlexKit /// /// Bewusst getrennt von `PlexStream`: VLCKit und AVPlayer nummerieren ihre /// Spuren selbst, und die Nummern stimmen nicht mit denen von Plex überein. -/// `engineIdentifier` hält deshalb fest, wie der jeweilige Motor die Spur +/// `engineIdentifier` hält deshalb fest, wie der jeweilige Engine die Spur /// nennt, während `plexStreamID` für den Server gedacht ist. public struct MediaTrack: Sendable, Equatable, Identifiable { public enum Kind: Sendable, Equatable { case audio, subtitle } @@ -15,7 +15,7 @@ public struct MediaTrack: Sendable, Equatable, Identifiable { public let displayName: String public let languageCode: String? public let plexStreamID: Int? - /// Die Nummer, unter der der laufende Motor die Spur führt. + /// Die Nummer, unter der der laufende Engine die Spur führt. public let engineIdentifier: Int? public let isForced: Bool diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift index d6830db..50cbe3d 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/PlaybackState.swift @@ -1,6 +1,6 @@ import Foundation -/// Der Zustand der Wiedergabe, unabhängig vom Motor. +/// Der Zustand der Wiedergabe, unabhängig vom Engine. public enum PlaybackState: Sendable, Equatable { case idle /// Quelle wird geöffnet, es ist noch nichts zu sehen. @@ -21,12 +21,12 @@ public enum PlaybackState: Sendable, Equatable { } /// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des -/// jeweiligen Motors. +/// jeweiligen Engine. public struct PlaybackFailure: Sendable, Equatable, Error { public enum Kind: Sendable, Equatable { /// Die Quelle war nicht erreichbar. case sourceUnavailable - /// Der Motor kann das Format nicht — sollte nach einer korrekten + /// Der Engine kann das Format nicht — sollte nach einer korrekten /// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf, /// dass die Fähigkeitenliste nachgezogen werden muss. case formatUnsupported diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift index b3c986c..28f04c0 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -3,7 +3,7 @@ import Foundation /// Woher das Bild kommt. public struct PlaybackSource: Sendable, Equatable { public enum Origin: Sendable, Equatable { - /// Vom Server — per DirectPlay oder als HLS-Strom des Transcoders. + /// Vom Server — per DirectPlay oder als HLS-Stream des Transcoders. case remote /// Aus dem Download-Ordner. case local @@ -22,11 +22,11 @@ public struct PlaybackSource: Sendable, Equatable { } } -/// Was ein Motor von sich aus meldet. +/// Was eine Engine von sich aus meldet. /// -/// Ein Strom statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO, +/// Ein Stream statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO, /// VLCKit über Delegat-Rückrufe. Beide in dieselbe Ereignisfolge zu -/// übersetzen hält den Unterschied an genau einer Stelle je Motor — statt in +/// übersetzen hält den Unterschied an genau einer Stelle je Engine — statt in /// jeder View, die den Zustand anzeigt. public enum PlaybackEvent: Sendable, Equatable { case stateChanged(PlaybackState) @@ -40,7 +40,7 @@ public enum PlaybackEvent: Sendable, Equatable { /// /// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus. /// Was hier nicht steht, gibt es für die Views nicht; dadurch bleibt der -/// Austausch des Motors unsichtbar. +/// Austausch des Engine unsichtbar. @MainActor public protocol VideoPlayerEngine: AnyObject { var events: AsyncStream { get } @@ -49,7 +49,7 @@ public protocol VideoPlayerEngine: AnyObject { var currentTime: Duration { get } var duration: Duration? { get } - /// Ob dieser Motor Bild-in-Bild kann. Steuert, ob der Knopf überhaupt + /// Ob dieser Engine Bild-in-Bild kann. Steuert, ob der Knopf überhaupt /// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist /// schlimmer als keiner. var supportsPictureInPicture: Bool { get } @@ -66,7 +66,52 @@ public protocol VideoPlayerEngine: AnyObject { /// `nil` schaltet die Untertitel ab. func selectSubtitleTrack(_ track: MediaTrack?) + /// Was gerade tatsächlich über die Leitung kommt. + /// + /// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der + /// Player selbst, welche Stufe er zieht, und die kann von der gewählten + /// Obergrenze abweichen. Ohne diese Angabe bleibt „ruckelt es, weil die + /// Leitung schlecht ist?" eine Vermutung. + var statistics: PlaybackStatistics? { get } + /// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen, /// sonst behält besonders VLCKit den Speicher. func teardown() } + + +/// Was über die Leitung kommt, während gespielt wird. +public struct PlaybackStatistics: Sendable, Equatable { + /// Was tatsächlich ankommt, in Kilobit je Sekunde. + public let observedBitrate: Int? + /// Was der Stream für sich angibt. + public let indicatedBitrate: Int? + public let width: Int? + public let height: Int? + /// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung. + public let droppedFrames: Int? + + public init( + observedBitrate: Int? = nil, + indicatedBitrate: Int? = nil, + width: Int? = nil, + height: Int? = nil, + droppedFrames: Int? = nil + ) { + self.observedBitrate = observedBitrate + self.indicatedBitrate = indicatedBitrate + self.width = width + self.height = height + self.droppedFrames = droppedFrames + } + + public var resolution: String? { + guard let width, let height, width > 0, height > 0 else { return nil } + return "\(width)×\(height)" + } +} + +extension VideoPlayerEngine { + /// Engines, die nichts melden, melden nichts — statt jeden zu zwingen. + public var statistics: PlaybackStatistics? { nil } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 1c3db9e..d46e713 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -385,7 +385,7 @@ extension PlexServer { /// Schaltet einen Sender ein. /// - /// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen + /// Live-TV kommt immer als umgerechneter Stream: der Tuner liefert einen /// Transportstrom, den kein iPhone direkt abspielt. /// /// Zwei Schritte, und der erste ist der, den ich zuerst übersprungen @@ -675,7 +675,7 @@ extension PlexServer { return lines.joined(separator: "\n") } - /// Belegt einen Tuner über den Empfänger und liefert den Strom. + /// Belegt einen Tuner über den Empfänger und liefert den Stream. /// /// Die Antwort auf das POST trägt den Vorgang mitsamt Metadaten-Eintrag, /// und **dort** steht die abspielbare Teil-Adresse — nicht im Raster und @@ -746,7 +746,7 @@ extension PlexServer { // Abwarten, bis der Empfänger abgestimmt hat. Er meldet // `status: inprogress` und `currentSize: 0` — es gibt in dem Moment - // noch keinen Strom, und jede Frage danach musste scheitern. Ein + // noch keinen Stream, und jede Frage danach musste scheitern. Ein // Tuner ist ein Stück Hardware; er braucht Sekunden, keine // Millisekunden. if let operation = Self.grabOperationKey(in: data) { @@ -861,7 +861,7 @@ extension PlexServer { /// /// Höchstens zwölf Sekunden. Länger zu warten hieße, den Nutzer vor einem /// schwarzen Bild sitzen zu lassen; gar nicht zu warten hieß bisher, nach - /// einem Strom zu fragen, den es noch nicht gibt. + /// einem Stream zu fragen, den es noch nicht gibt. private func waitForGrab(atKey key: String, limit: Int = 12) async -> String { for second in 1...limit { try? await Task.sleep(for: .seconds(1)) @@ -1180,7 +1180,7 @@ extension PlexServer { return keys.filter { !$0.isEmpty && seen.insert($0).inserted } } - /// Fragt den Server, ob er diesen Strom zulässt — und warum nicht. + /// Fragt den Server, ob er diesen Stream zulässt — und warum nicht. public func liveTVVerdict(for url: URL) async -> LiveTVVerdict { // Dieselben Angaben, nur an den Entscheider statt an den Transcoder. // Er antwortet im Klartext, warum er ablehnt — sonst bleibt nur „die @@ -1450,7 +1450,7 @@ public struct LiveTuneResult: Sendable { public let trace: [ChannelProbe] } -/// Was der Server zu einem Strom sagt. +/// Was der Server zu einem Stream sagt. public enum LiveTVVerdict: Sendable, Equatable { case allowed /// Ausdrücklich abgelehnt, mit Begründung. diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 71c087f..2f0e79d 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -3,7 +3,7 @@ import Foundation extension PlexServer { // MARK: - Transcoding - /// Die Adresse des HLS-Stroms für diese Wiedergabe. + /// Die Adresse des HLS-Streams für diese Wiedergabe. /// /// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die /// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung @@ -74,7 +74,7 @@ extension PlexServer { return components.url } - /// Baut die Adresse eines Stroms, den der Server schon bereitstellt. + /// Baut die Adresse eines Streams, den der Server schon bereitstellt. /// /// Für Live-TV. Token und Gerätekennung müssen in die Adresse, weil sie /// der Systemplayer abruft und der keine Kopfzeilen mitschickt. @@ -128,7 +128,7 @@ extension PlexServer { /// /// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen /// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS - /// wirklich kann, schickte Plex einen Strom, der hier stumm bliebe oder + /// wirklich kann, schickte Plex einen Stream, der hier stumm bliebe oder /// schwarz. private nonisolated var clientProfileItems: [URLQueryItem] { [ @@ -282,7 +282,7 @@ extension PlexServer { /// Die Adresse für einen heruntergerechneten Download. /// /// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den - /// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Strom + /// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Stream /// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der /// umständlich und schlecht dokumentiert ist. /// @@ -290,7 +290,7 @@ extension PlexServer { /// bevor die Einstellung angeboten wird: /// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen /// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren. - /// 2. Ob die entstehende Datei durchsuchbar ist. Ein Strom, der von vorn + /// 2. Ob die entstehende Datei durchsuchbar ist. Ein Stream, der von vorn /// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht /// springen. /// @@ -321,7 +321,7 @@ extension PlexServer { components.queryItems = request.queryItems // Überschreibt `protocol=hls` aus der Anfrage: für eine Datei - // brauchen wir einen fortlaufenden Strom, keine Segmentliste. + // brauchen wir einen fortlaufenden Stream, keine Segmentliste. .filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" } + [ URLQueryItem(name: "protocol", value: "http"), diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift index 958b347..71e975b 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift @@ -84,7 +84,7 @@ public struct TranscodeRequest: Sendable, Equatable { // Ohne fastSeek muss der Transcoder beim Springen von vorn // beginnen — bei einem Zweistünder dauert das ewig. URLQueryItem(name: "fastSeek", value: "1"), - // 0 heißt: wir fragen hier ausdrücklich nach einem Strom. Ob + // 0 heißt: wir fragen hier ausdrücklich nach einem Stream. Ob // DirectPlay möglich wäre, hat der PlaybackDecider schon geprüft. URLQueryItem(name: "directPlay", value: "0"), // 1 erlaubt das bloße Umpacken, wenn der Bildcodec passt. Das diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index 383c15f..00250dc 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -291,7 +291,7 @@ struct PlexGridMedia: Decodable { } } -/// Der Strom hinter einer Rastersendung. +/// Der Stream hinter einer Rastersendung. struct PlexGridPart: Decodable { let key: String? } diff --git a/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift b/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift index d0bc5f5..6863ac4 100644 --- a/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift +++ b/Packages/FlixyKit/Tests/FlixyDesignTests/MiniPlayerPlacementTests.swift @@ -13,42 +13,55 @@ struct MiniPlayerPlacementTests { inset: 12 ) - @Test("Jede Bildschirmhälfte führt in ihre Ecke") - func picksTheNearestCorner() { - #expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 50)) == .topLeading) - #expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 50)) == .topTrailing) - #expect(placement.nearestCorner(toCenter: CGPoint(x: 50, y: 800)) == .bottomLeading) - #expect(placement.nearestCorner(toCenter: CGPoint(x: 340, y: 800)) == .bottomTrailing) + @Test("Die nähere Seite gewinnt") + func picksTheNearestSide() { + #expect(placement.nearestSide(toCenter: CGPoint(x: 50, y: 400)) == .leading) + #expect(placement.nearestSide(toCenter: CGPoint(x: 340, y: 400)) == .trailing) } - @Test("Genau auf der Mitte gewinnt unten rechts") - func decidesTheTie() { - // Willkürlich, aber festgelegt: ohne eine Regel für den Gleichstand - // hinge das Ergebnis an Fließkomma-Zufall, und dieselbe Geste führte - // mal hierhin, mal dorthin. - let center = CGPoint(x: 195, y: 422) + @Test("Die Höhe bleibt frei, nicht auf Ecken festgelegt") + func keepsTheVerticalPosition() { + // Der Kern des Umbaus: vier Ecken sind schnell gebaut, aber im Weg. + let middle = placement.center(side: .trailing, verticalFraction: 0.5) + let top = placement.center(side: .trailing, verticalFraction: 0) + let bottom = placement.center(side: .trailing, verticalFraction: 1) - #expect(placement.nearestCorner(toCenter: center) == .bottomTrailing) + #expect(top.y < middle.y) + #expect(middle.y < bottom.y) + #expect(top.x == middle.x && middle.x == bottom.x) } - @Test("In jeder Ecke bleibt der Sicherheitsabstand") - func keepsTheInset() { - for corner in MiniPlayerPlacement.Corner.allCases { - let start = placement.origin(for: corner) + @Test("Anteil und Mittelpunkt rechnen ineinander um") + func roundTripsTheFraction() { + for fraction in [0.0, 0.25, 0.5, 0.9, 1.0] { + let center = placement.center(side: .leading, verticalFraction: fraction) + let back = placement.verticalFraction(forCenter: center) - #expect(start.x >= placement.inset) - #expect(start.y >= placement.inset) - #expect(start.x + placement.tile.width <= 390 - placement.inset) - #expect(start.y + placement.tile.height <= 844 - placement.inset) + #expect(abs(back - fraction) < 0.0001) } } - @Test("Unten rechts sitzt die Kachel auch wirklich unten rechts") - func computesTheFarCorner() { - let start = placement.origin(for: .bottomTrailing) + @Test("Der Anteil bleibt zwischen null und eins") + func clampsTheFraction() { + // Sonst wandert die Kachel beim Drehen aus dem Bild: der Anteil wird + // gespeichert, und ein Wert über eins landete unterhalb des Randes. + #expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: -9_000)) == 0) + #expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: 9_000)) == 1) + #expect(placement.center(side: .leading, verticalFraction: 5).y <= 844 - 12) + } - #expect(start.x == 198.0) - #expect(start.y == 731.0) + @Test("An jeder Seite bleibt der Sicherheitsabstand") + func keepsTheInset() { + for side in MiniPlayerPlacement.Side.allCases { + for fraction in [0.0, 0.5, 1.0] { + let center = placement.center(side: side, verticalFraction: fraction) + + #expect(center.x - 90 >= 12) + #expect(center.x + 90 <= 390 - 12) + #expect(center.y - 50.5 >= 12) + #expect(center.y + 50.5 <= 844 - 12) + } + } } @Test("Beim Ziehen rutscht nichts über den Rand") @@ -82,7 +95,7 @@ struct MiniPlayerPlacementTests { inset: 12 ) - #expect(narrow.origin(for: .bottomTrailing) == CGPoint(x: 12, y: 12)) #expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5)) + #expect(narrow.center(side: .trailing, verticalFraction: 1) == CGPoint(x: 102, y: 62.5)) } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift index 2d9b6c8..f54e91e 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -106,7 +106,7 @@ struct PlaybackDeciderTests { #expect(decision.method == .directPlay(engine: .avPlayer)) } - @Test("Unanalysierte Dateien gehen an den toleranteren Motor") + @Test("Unanalysierte Dateien gehen an den toleranteren Engine") func unknownStreamsFallBackToVLC() { let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: []) let media = PlexMedia(id: 100, container: "mp4", parts: [part]) diff --git a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift index 119cb00..e2a841a 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift +++ b/Packages/FlixyKit/Tests/PlexKitTests/LiveTVGuideTests.swift @@ -331,7 +331,7 @@ struct LiveTVGuideTests { #expect(path == "/livetv/sessions/xyz/stream") } - @Test("Der Tuner wird per POST belegt, und die Antwort trägt den Strom") + @Test("Der Tuner wird per POST belegt, und die Antwort trägt den Stream") func tunesThroughDVRWithPost() async throws { // Nachgelesen im Quelltext von Plex' eigenem Client: // POST /livetv/dvrs/{dvr}/channels/{kennung}/tune