Diagnose-Bildschirm und Build-Datum
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 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,23 @@ enum Branding {
|
|||||||
URL(string: "\(urlScheme)://auth")!
|
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`.
|
/// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`.
|
||||||
static var version: String {
|
static var version: String {
|
||||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct FlixyApp: App {
|
|||||||
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
/// läuft statt bei jedem Öffnen des Players neu zu starten.
|
||||||
@State private var network = NetworkConditions()
|
@State private var network = NetworkConditions()
|
||||||
@State private var homeLayout = HomeLayoutSettings()
|
@State private var homeLayout = HomeLayoutSettings()
|
||||||
|
@State private var diagnostics = PlaybackDiagnostics()
|
||||||
@State private var downloads: DownloadCoordinator
|
@State private var downloads: DownloadCoordinator
|
||||||
|
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
@@ -59,6 +60,7 @@ struct FlixyApp: App {
|
|||||||
.environment(network)
|
.environment(network)
|
||||||
.environment(downloads)
|
.environment(downloads)
|
||||||
.environment(homeLayout)
|
.environment(homeLayout)
|
||||||
|
.environment(diagnostics)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
// Der eigentliche Schalter für „nur dunkel" steht in der
|
||||||
|
|||||||
@@ -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": {
|
"player.skipCredits": {
|
||||||
"extractionState": "stale",
|
"extractionState": "stale",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -3162,19 +3180,227 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player.relayWarning": {
|
"settings.build": {
|
||||||
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
|
"comment": "Zeitpunkt, zu dem der laufende Build entstanden ist",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"de": {
|
"de": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"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": {
|
"en": {
|
||||||
"stringUnit": {
|
"stringUnit": {
|
||||||
"state": "translated",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ final class PlayerModel {
|
|||||||
private let request: PlaybackRequest
|
private let request: PlaybackRequest
|
||||||
private let server: PlexServer?
|
private let server: PlexServer?
|
||||||
private let downloads: DownloadCoordinator
|
private let downloads: DownloadCoordinator
|
||||||
|
private let diagnostics: PlaybackDiagnostics
|
||||||
private let policy: PlaybackPolicy
|
private let policy: PlaybackPolicy
|
||||||
private let settings: PlaybackSettings
|
private let settings: PlaybackSettings
|
||||||
private let isLocalConnection: Bool
|
private let isLocalConnection: Bool
|
||||||
@@ -66,6 +67,7 @@ final class PlayerModel {
|
|||||||
request: PlaybackRequest,
|
request: PlaybackRequest,
|
||||||
server: PlexServer?,
|
server: PlexServer?,
|
||||||
downloads: DownloadCoordinator,
|
downloads: DownloadCoordinator,
|
||||||
|
diagnostics: PlaybackDiagnostics,
|
||||||
policy: PlaybackPolicy,
|
policy: PlaybackPolicy,
|
||||||
settings: PlaybackSettings,
|
settings: PlaybackSettings,
|
||||||
isLocalConnection: Bool
|
isLocalConnection: Bool
|
||||||
@@ -73,6 +75,7 @@ final class PlayerModel {
|
|||||||
self.request = request
|
self.request = request
|
||||||
self.server = server
|
self.server = server
|
||||||
self.downloads = downloads
|
self.downloads = downloads
|
||||||
|
self.diagnostics = diagnostics
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.isLocalConnection = isLocalConnection
|
self.isLocalConnection = isLocalConnection
|
||||||
@@ -82,6 +85,8 @@ final class PlayerModel {
|
|||||||
// MARK: - Start
|
// MARK: - Start
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
|
diagnostics.begin(title: request.title, connection: server?.connection)
|
||||||
|
|
||||||
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
|
// Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
|
||||||
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
|
// Datenvolumen und funktioniert auch dann, wenn der Server gerade
|
||||||
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
|
||||||
@@ -95,10 +100,13 @@ final class PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diagnostics.note("Metadaten anfragen")
|
||||||
let item: PlexItem
|
let item: PlexItem
|
||||||
do {
|
do {
|
||||||
item = try await server.metadata(ratingKey: request.ratingKey)
|
item = try await server.metadata(ratingKey: request.ratingKey)
|
||||||
|
diagnostics.note("Metadaten da")
|
||||||
} catch {
|
} catch {
|
||||||
|
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))")
|
||||||
phase = .failed(message: PlexErrorText.describe(error))
|
phase = .failed(message: PlexErrorText.describe(error))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,16 +181,21 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
|
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
|
||||||
|
diagnostics.record(decision: decision, source: source.url)
|
||||||
|
|
||||||
let engine = makeEngine(for: decision)
|
let engine = makeEngine(for: decision)
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
observe(engine)
|
observe(engine)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
diagnostics.note("Motor öffnet Quelle")
|
||||||
try await engine.load(source)
|
try await engine.load(source)
|
||||||
engine.play()
|
engine.play()
|
||||||
phase = .playing
|
phase = .playing
|
||||||
|
diagnostics.note("Motor gestartet")
|
||||||
if !isOffline { startReporting() }
|
if !isOffline { startReporting() }
|
||||||
} catch {
|
} catch {
|
||||||
|
diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)")
|
||||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,7 +383,11 @@ final class PlayerModel {
|
|||||||
switch event {
|
switch event {
|
||||||
case .stateChanged(let state):
|
case .stateChanged(let state):
|
||||||
isPlaying = state == .playing
|
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"))
|
phase = .failed(message: String(localized: "player.playbackFailed"))
|
||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let time):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct PlayerView: View {
|
|||||||
@Environment(PlaybackSettings.self) private var settings
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@Environment(NetworkConditions.self) private var network
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(DownloadCoordinator.self) private var downloads
|
@Environment(DownloadCoordinator.self) private var downloads
|
||||||
|
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var model: PlayerModel?
|
@State private var model: PlayerModel?
|
||||||
@@ -75,6 +76,7 @@ struct PlayerView: View {
|
|||||||
request: request,
|
request: request,
|
||||||
server: session.server,
|
server: session.server,
|
||||||
downloads: downloads,
|
downloads: downloads,
|
||||||
|
diagnostics: diagnostics,
|
||||||
policy: settings.policy(for: network),
|
policy: settings.policy(for: network),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
isLocalConnection: session.server?.connection.local ?? false
|
isLocalConnection: session.server?.connection.local ?? false
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,12 +68,19 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("homeLayout.title", systemImage: "rectangle.stack")
|
Label("homeLayout.title", systemImage: "rectangle.stack")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
DiagnosticsView()
|
||||||
|
} label: {
|
||||||
|
Label("diagnostics.title", systemImage: "stethoscope")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadSection
|
downloadSection
|
||||||
|
|
||||||
Section("settings.app") {
|
Section("settings.app") {
|
||||||
LabeledContent("settings.version", value: Branding.version)
|
LabeledContent("settings.version", value: Branding.version)
|
||||||
|
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
Reference in New Issue
Block a user