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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user