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:
Guido
2026-09-11 20:56:46 +02:00
co-authored by Claude Opus 5
parent 7170595436
commit f10f0c4086
8 changed files with 467 additions and 5 deletions
+18 -1
View File
@@ -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):
+2
View File
@@ -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
@@ -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"))
"""
}
}
+7
View File
@@ -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 {