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 {