Bidirectional progress sync, reliable background history, MB-accurate download ring

- Bidirectional progress sync: server's `lastUpdate` now parsed correctly; pull
  timer (60s) + scenePhase hook reconcile against local state. Server-newer
  while paused/playing stashes a `pendingServerProgress` and surfaces a prompt
  on next Play; server-older triggers an immediate push.
- History: lockscreen/Control-Center skip & scrub now route through AppState
  via `onRemoteSkip`/`onRemoteSeek` callbacks (previously bypassed history).
  `AppState.skip(by:)` itself now records the pre-skip position.
- Chapter detection moved to the AVPlayer periodic time observer — fires
  reliably while the app is backgrounded or the device is locked, where the
  5s runloop Timer can be throttled.
- Always fetch item detail when online (even for downloaded items) so
  `item.chapters` is populated and history entries get chapter titles.
- DownloadManager: per-track byte-fraction progress, so single-track 1+GB
  audiobooks' ring grows smoothly instead of staying at 0% until done.
- PlayerBar: extracted ScrubberView into its own struct so per-second time
  updates no longer re-render the parent (fixes iOS history-popup flicker).
- App icon: re-embedded sRGB profile in marketing icon; bumped version 2.0
  to 2.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-27 20:44:38 +02:00
parent fa47cae664
commit 9497c6e315
8 changed files with 271 additions and 69 deletions

View File

@@ -2,6 +2,7 @@ import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@State private var splashVisible = true
#endif
@@ -18,6 +19,9 @@ struct ContentView: View {
#endif
}
.task { await boot() }
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { app.onScenePhaseActive() }
}
}
@ViewBuilder

View File

@@ -8,6 +8,7 @@ struct PlayerBar: View {
@State private var scrubValue: Double = 0
@State private var showDetails: Bool = false
@State private var showFullHistory: Bool = false
@State private var showResumePrompt: Bool = false
var body: some View {
if let item = app.currentItem {
@@ -32,6 +33,16 @@ struct PlayerBar: View {
FullHistoryView()
.environment(app)
}
.alert("Neuerer Stand auf dem Server", isPresented: $showResumePrompt, presenting: app.pendingServerProgress) { server in
Button("Hier weiter (\(formatTime(app.player.absoluteCurrentTime)))") {
app.dismissPendingServerProgress()
}
Button("Server-Stand übernehmen (\(formatTime(server.currentTime)))") {
app.acceptPendingServerProgress()
}
} message: { server in
Text("Auf einem anderen Gerät wurde dieses Hörbuch bis \(formatTime(server.currentTime)) gehört.")
}
} else if app.isPreparingPlayback {
VStack(spacing: 0) {
Divider()
@@ -64,7 +75,7 @@ struct PlayerBar: View {
}
}
Spacer(minLength: 0)
Button { app.togglePlay() } label: {
Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
}
@@ -173,7 +184,7 @@ struct PlayerBar: View {
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.togglePlay() } label: {
Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 34))
}
@@ -231,41 +242,7 @@ struct PlayerBar: View {
}
private var scrubber: some View {
VStack(spacing: 2) {
Slider(
value: Binding(
get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime },
set: { scrubValue = $0; scrubbing = true }
),
in: 0...max(app.player.totalDuration, 1),
onEditingChanged: { editing in
if !editing {
app.seekAbsolute(scrubValue)
scrubbing = false
}
}
)
.disabled(!app.player.isReady)
HStack {
Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(formatTime(app.player.totalDuration))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
if app.player.sleepTimer != .off {
HStack(spacing: 4) {
Image(systemName: "moon.zzz.fill")
.font(.caption2)
Text("\(formatTime(app.player.sleepRemainingSeconds)) · endet \(formatWallTime(sleepEndsAt))")
.font(.caption2.monospacedDigit())
}
.foregroundStyle(.tint)
.frame(maxWidth: .infinity, alignment: .center)
}
}
ScrubberView(scrubbing: $scrubbing, scrubValue: $scrubValue)
}
private var detailsButtonVisible: Bool {
@@ -356,18 +333,20 @@ struct PlayerBar: View {
}
}
private var sleepEndsAt: Date {
Date().addingTimeInterval(app.player.sleepRemainingSeconds)
}
private var endOfPlaybackLabel: String {
app.currentItem?.isPodcast == true
? "Bis Ende der Folge"
: "Bis Ende des Hörbuchs"
}
private func formatWallTime(_ date: Date) -> String {
date.formatted(.dateTime.hour().minute())
/// Intercept Play to surface the resume-prompt if another device pushed a
/// newer position while we were paused/playing.
private func handlePlayTap() {
if !app.player.isPlaying, app.pendingServerProgress != nil {
showResumePrompt = true
} else {
app.togglePlay()
}
}
private var rateMenu: some View {
@@ -431,3 +410,71 @@ struct PlayerBar: View {
return String(format: "%d:%02d", m, s)
}
}
/// Extracted into its own View so that per-second updates to
/// `player.absoluteCurrentTime` / `sleepRemainingSeconds` only re-render this
/// subtree not the entire `PlayerBar` (which would also rebuild open menus,
/// causing visible flicker on iOS).
private struct ScrubberView: View {
@Environment(AppState.self) private var app
@Binding var scrubbing: Bool
@Binding var scrubValue: Double
var body: some View {
VStack(spacing: 2) {
Slider(
value: Binding(
get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime },
set: { scrubValue = $0; scrubbing = true }
),
in: 0...max(app.player.totalDuration, 1),
onEditingChanged: { editing in
if !editing {
app.seekAbsolute(scrubValue)
scrubbing = false
}
}
)
.disabled(!app.player.isReady)
HStack {
Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(formatTime(app.player.totalDuration))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
if app.player.sleepTimer != .off {
HStack(spacing: 4) {
Image(systemName: "moon.zzz.fill")
.font(.caption2)
Text("\(formatTime(app.player.sleepRemainingSeconds)) · endet \(formatWallTime(sleepEndsAt))")
.font(.caption2.monospacedDigit())
}
.foregroundStyle(.tint)
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
private var sleepEndsAt: Date {
Date().addingTimeInterval(app.player.sleepRemainingSeconds)
}
private func formatWallTime(_ date: Date) -> String {
date.formatted(.dateTime.hour().minute())
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let total = Int(seconds)
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 {
return String(format: "%d:%02d:%02d", h, m, s)
}
return String(format: "%d:%02d", m, s)
}
}