From f10f0c4086f14d0fc15abbb3a1b7897353e139ec Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 11 Sep 2026 20:56:46 +0200 Subject: [PATCH] Diagnose-Bildschirm und Build-Datum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zweimal in Folge stand „es läuft nicht" gegen „bei mir ist es behoben", ohne dass sich klären ließ, welcher Stand überhaupt auf dem Gerät liegt. Das soll nicht noch einmal passieren. Einstellungen → Diagnose zeigt jetzt: - welcher Server und über welche Verbindung, mit Warnung bei der gedrosselten Plex-Vermittlung - den letzten Abspielversuch: Titel, gewählter Weg, Quelladresse - jeden Schritt mit Zeitabstand zum Start. Der Abstand ist die eigentliche Auskunft: liegt zwischen „Quelle gewählt" und „spielt" eine Minute, hängt es an der Leitung; kommt „spielt" gar nicht, am Motor. - den Zeitpunkt, zu dem der laufende Build entstanden ist Das Build-Datum kommt aus dem Änderungsdatum der eigenen Binärdatei — kein Build-Skript, keine erzeugte Datei, und immer richtig. Der Bericht lässt sich kopieren und enthält keinen Token: die Quelladresse wird ohne Abfrageteil abgelegt, und dort steckt er. Co-Authored-By: Claude Opus 5 --- Flixy/Branding.swift | 17 ++ Flixy/FlixyApp.swift | 2 + Flixy/Resources/Localizable.xcstrings | 234 ++++++++++++++++++++- Flixy/Session/PlaybackDiagnostics.swift | 92 ++++++++ Flixy/Views/Player/PlayerModel.swift | 19 +- Flixy/Views/Player/PlayerView.swift | 2 + Flixy/Views/Settings/DiagnosticsView.swift | 99 +++++++++ Flixy/Views/Settings/SettingsView.swift | 7 + 8 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 Flixy/Session/PlaybackDiagnostics.swift create mode 100644 Flixy/Views/Settings/DiagnosticsView.swift diff --git a/Flixy/Branding.swift b/Flixy/Branding.swift index 9ce3cf4..dbd03f5 100644 --- a/Flixy/Branding.swift +++ b/Flixy/Branding.swift @@ -42,6 +42,23 @@ enum Branding { URL(string: "\(urlScheme)://auth")! } + /// Wann dieser Build entstanden ist. + /// + /// Gelesen als Änderungsdatum der eigenen Binärdatei — kein Build-Skript, + /// keine erzeugte Datei, und immer richtig: die Binärdatei entsteht genau + /// dann, wenn gebaut wird. + /// + /// Steht in den Einstellungen. Ohne diese Angabe lässt sich nicht + /// unterscheiden, ob ein Fehler noch besteht oder ob nur ein alter Build + /// auf dem Gerät liegt — und das kostet jedes Mal eine Rückfrage. + static var buildStamp: String { + guard let url = Bundle.main.executableURL, + let date = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + else { return "—" } + return date.formatted(date: .abbreviated, time: .shortened) + } + /// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`. static var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 3605bd6..ccf54ba 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -23,6 +23,7 @@ struct FlixyApp: App { /// läuft statt bei jedem Öffnen des Players neu zu starten. @State private var network = NetworkConditions() @State private var homeLayout = HomeLayoutSettings() + @State private var diagnostics = PlaybackDiagnostics() @State private var downloads: DownloadCoordinator private let modelContainer: ModelContainer @@ -59,6 +60,7 @@ struct FlixyApp: App { .environment(network) .environment(downloads) .environment(homeLayout) + .environment(diagnostics) .environment(theme) .flixyAccent(theme) // Der eigentliche Schalter für „nur dunkel" steht in der diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 8f1ce6d..1f6c07c 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1689,6 +1689,24 @@ } } }, + "player.relayWarning": { + "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." + } + } + } + }, "player.skipCredits": { "extractionState": "stale", "localizations": { @@ -3162,19 +3180,227 @@ } } }, - "player.relayWarning": { - "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", + "settings.build": { + "comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." + "value": "Build" } }, "en": { "stringUnit": { "state": "translated", - "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." + "value": "Build" + } + } + } + }, + "diagnostics.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diagnose" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + } + } + }, + "diagnostics.connection": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" + } + } + } + }, + "diagnostics.noServer": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Serververbindung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No server connection." + } + } + } + }, + "diagnostics.relayWarning": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Via the Plex relay. Heavily throttled — large files barely start over it." + } + } + } + }, + "diagnostics.lastAttempt": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzter Abspielversuch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Last playback attempt" + } + } + } + }, + "diagnostics.nothingYet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nichts abgespielt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing played yet." + } + } + } + }, + "diagnostics.title.label": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + } + } + }, + "diagnostics.method": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weg" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Method" + } + } + } + }, + "diagnostics.connectionUsed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" + } + } + } + }, + "diagnostics.source": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle (ohne Token)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Source (token removed)" + } + } + } + }, + "diagnostics.steps": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablauf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timeline" + } + } + } + }, + "diagnostics.copy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bericht kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy report" + } + } + } + }, + "diagnostics.copyHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthält keinen Token. Kannst du unbesorgt weitergeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Contains no token. Safe to share." } } } diff --git a/Flixy/Session/PlaybackDiagnostics.swift b/Flixy/Session/PlaybackDiagnostics.swift new file mode 100644 index 0000000..40026cb --- /dev/null +++ b/Flixy/Session/PlaybackDiagnostics.swift @@ -0,0 +1,92 @@ +import Foundation +import Observation +import FlixyPlayback +import PlexKit + +/// Hält fest, was beim letzten Abspielversuch passiert ist. +/// +/// Der Player verschwindet, wenn man ihn schließt — und mit ihm jeder Hinweis +/// darauf, woran es lag. Dieser Mitschnitt überlebt das und steht in den +/// Einstellungen. Aus „es läuft nicht" wird damit „VLC, lokale Verbindung, +/// hängt bei Öffnen" — und das ist ein Unterschied, der Stunden spart. +@MainActor +@Observable +final class PlaybackDiagnostics { + struct Attempt: Identifiable { + let id = UUID() + let startedAt: Date + var title: String + var method: String + var connection: String + /// Die Adresse ohne Abfrageteil. + /// + /// Der Token steckt in der Abfrage. Er hat in einem Protokoll nichts + /// zu suchen — es landet in Screenshots, und wer ihn hat, kommt an die + /// ganze Mediathek. + var sourceDescription: String + var steps: [String] = [] + var outcome: String? + } + + private(set) var lastAttempt: Attempt? + + func begin(title: String, connection: PlexConnection?) { + lastAttempt = Attempt( + startedAt: Date(), + title: title, + method: "—", + connection: connection.map(Self.describe) ?? "—", + sourceDescription: "—" + ) + note("Player geöffnet") + } + + func record(decision: PlaybackDecision, source: URL) { + lastAttempt?.method = Self.describe(decision) + lastAttempt?.sourceDescription = Self.describe(source) + note("Quelle gewählt") + } + + /// Ein Zwischenschritt, mit Zeitabstand zum Beginn. + /// + /// 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. + func note(_ step: String) { + guard var attempt = lastAttempt else { return } + let elapsed = Date().timeIntervalSince(attempt.startedAt) + attempt.steps.append(String(format: "%.1fs %@", elapsed, step)) + lastAttempt = attempt + } + + func finish(outcome: String) { + lastAttempt?.outcome = outcome + note(outcome) + } + + private static func describe(_ decision: PlaybackDecision) -> String { + switch decision.method { + case .directPlay(let engine): + switch engine { + case .avPlayer: "Direkt · AVPlayer" + case .vlc: "Direkt · VLC" + } + case .transcode(let limit): + limit.map { "Transcode · max. \($0 / 1000) Mbit" } ?? "Transcode" + } + } + + private static func describe(_ connection: PlexConnection) -> String { + if connection.local { return "Lokal · \(connection.uri)" } + if connection.relay { return "Plex-Vermittlung (gedrosselt)" } + return "Direkt von außen · \(connection.uri)" + } + + private static func describe(_ url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "—" + } + components.query = nil + return components.string ?? "—" + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 3d5dbcd..fad3199 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -49,6 +49,7 @@ final class PlayerModel { private let request: PlaybackRequest private let server: PlexServer? private let downloads: DownloadCoordinator + private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool @@ -66,6 +67,7 @@ final class PlayerModel { request: PlaybackRequest, server: PlexServer?, downloads: DownloadCoordinator, + diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, isLocalConnection: Bool @@ -73,6 +75,7 @@ final class PlayerModel { self.request = request self.server = server self.downloads = downloads + self.diagnostics = diagnostics self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection @@ -82,6 +85,8 @@ final class PlayerModel { // MARK: - Start func start() async { + diagnostics.begin(title: request.title, connection: server?.connection) + // 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. @@ -95,10 +100,13 @@ final class PlayerModel { return } + diagnostics.note("Metadaten anfragen") let item: PlexItem do { item = try await server.metadata(ratingKey: request.ratingKey) + diagnostics.note("Metadaten da") } catch { + diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))") phase = .failed(message: PlexErrorText.describe(error)) return } @@ -173,16 +181,21 @@ final class PlayerModel { } private func load(_ source: PlaybackSource, decision: PlaybackDecision) async { + diagnostics.record(decision: decision, source: source.url) + let engine = makeEngine(for: decision) self.engine = engine observe(engine) do { + diagnostics.note("Motor öffnet Quelle") try await engine.load(source) engine.play() phase = .playing + diagnostics.note("Motor gestartet") if !isOffline { startReporting() } } catch { + diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)") phase = .failed(message: String(localized: "player.loadFailed")) } } @@ -370,7 +383,11 @@ final class PlayerModel { switch event { case .stateChanged(let state): isPlaying = state == .playing - if case .failed = state { + // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist + // abzulesen, ob der Motor hängt oder die Leitung. + diagnostics.note("Zustand: \(state)") + if case .failed(let failure) = state { + diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)") phase = .failed(message: String(localized: "player.playbackFailed")) } case .timeChanged(let time): diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index baf9fdd..4000190 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -13,6 +13,7 @@ struct PlayerView: View { @Environment(PlaybackSettings.self) private var settings @Environment(NetworkConditions.self) private var network @Environment(DownloadCoordinator.self) private var downloads + @Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(\.flixyAccent) private var accent @State private var model: PlayerModel? @@ -75,6 +76,7 @@ struct PlayerView: View { request: request, server: session.server, downloads: downloads, + diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, isLocalConnection: session.server?.connection.local ?? false diff --git a/Flixy/Views/Settings/DiagnosticsView.swift b/Flixy/Views/Settings/DiagnosticsView.swift new file mode 100644 index 0000000..a818a51 --- /dev/null +++ b/Flixy/Views/Settings/DiagnosticsView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import FlixyDesign +import PlexKit + +/// Was beim letzten Abspielversuch passiert ist. +/// +/// Gedacht als Antwort auf „es läuft nicht": hier steht, welcher Motor gewählt +/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat. +/// Ein Screenshot davon sagt mehr als jede Beschreibung. +struct DiagnosticsView: View { + @Environment(FlixySession.self) private var session + @Environment(PlaybackDiagnostics.self) private var diagnostics + + var body: some View { + Form { + Section("diagnostics.connection") { + if let server = session.server { + LabeledContent("settings.serverName", value: server.name) + LabeledContent("settings.address", value: server.connection.uri) + .font(.flixyCaption) + + if server.connection.relay { + // Der häufigste Grund dafür, dass nichts anläuft. Ohne + // Hinweis sucht man den Fehler in der App. + Label("diagnostics.relayWarning", systemImage: "exclamationmark.triangle") + .font(.flixyCaption) + .foregroundStyle(Color.flixyDestructive) + } + } else { + Text("diagnostics.noServer").foregroundStyle(Color.flixyTextMuted) + } + } + + if let attempt = diagnostics.lastAttempt { + Section("diagnostics.lastAttempt") { + LabeledContent("diagnostics.title.label", value: attempt.title) + LabeledContent("diagnostics.method", value: attempt.method) + LabeledContent("diagnostics.connectionUsed", value: attempt.connection) + + VStack(alignment: .leading, spacing: 2) { + Text("diagnostics.source") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + Text(attempt.sourceDescription) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(Color.flixyTextSecondary) + } + } + + Section("diagnostics.steps") { + ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in + Text(step) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Color.flixyTextSecondary) + } + } + + Section { + Button { + UIPasteboard.general.string = report(for: attempt) + } label: { + Label("diagnostics.copy", systemImage: "doc.on.doc") + } + } footer: { + Text("diagnostics.copyHint") + } + } else { + Section("diagnostics.lastAttempt") { + Text("diagnostics.nothingYet").foregroundStyle(Color.flixyTextMuted) + } + } + + Section("settings.app") { + LabeledContent("settings.version", value: Branding.version) + LabeledContent("settings.build", value: Branding.buildStamp) + } + } + .scrollContentBackground(.hidden) + .background(Color.flixyCanvas) + .navigationTitle(Text("diagnostics.title")) + .navigationBarTitleDisplayMode(.inline) + } + + /// Der Bericht als Text zum Weitergeben. + /// + /// Enthält bewusst keinen Token: die Quelladresse ist schon ohne + /// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches. + private func report(for attempt: PlaybackDiagnostics.Attempt) -> String { + """ + Flixy \(Branding.version) · Build \(Branding.buildStamp) + Verbindung: \(attempt.connection) + Titel: \(attempt.title) + Weg: \(attempt.method) + Quelle: \(attempt.sourceDescription) + + \(attempt.steps.joined(separator: "\n")) + """ + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index bb0f8e6..5e797dd 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -68,12 +68,19 @@ struct SettingsView: View { } label: { Label("homeLayout.title", systemImage: "rectangle.stack") } + + NavigationLink { + DiagnosticsView() + } label: { + Label("diagnostics.title", systemImage: "stethoscope") + } } downloadSection Section("settings.app") { LabeledContent("settings.version", value: Branding.version) + LabeledContent("settings.build", value: Branding.buildStamp) } Section {