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

@@ -287,7 +287,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 26.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client"; PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -341,7 +341,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 26.0; MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client"; PRODUCT_NAME = "ABS Client";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 726 KiB

View File

@@ -167,10 +167,15 @@ final class ABSClient {
currentTime: dto.currentTime ?? 0, currentTime: dto.currentTime ?? 0,
duration: dto.duration ?? 0, duration: dto.duration ?? 0,
isFinished: dto.isFinished ?? false, isFinished: dto.isFinished ?? false,
updatedAt: Date() updatedAt: Self.parseLastUpdate(dto.lastUpdate)
) )
} }
private static func parseLastUpdate(_ ms: Double?) -> Date {
guard let ms, ms > 0 else { return Date() }
return Date(timeIntervalSince1970: ms / 1000)
}
func saveProgress(_ progress: PlaybackProgress) async throws { func saveProgress(_ progress: PlaybackProgress) async throws {
let body: [String: Any] = [ let body: [String: Any] = [
"currentTime": progress.currentTime, "currentTime": progress.currentTime,
@@ -200,7 +205,7 @@ final class ABSClient {
currentTime: p.currentTime ?? 0, currentTime: p.currentTime ?? 0,
duration: p.duration ?? 0, duration: p.duration ?? 0,
isFinished: p.isFinished ?? false, isFinished: p.isFinished ?? false,
updatedAt: Date() updatedAt: Self.parseLastUpdate(p.lastUpdate)
) )
} }
} }

View File

@@ -23,9 +23,15 @@ final class AppState {
/// Used to show progress bars on covers in the library views. /// Used to show progress bars on covers in the library views.
var progressCache: [String: PlaybackProgress] = [:] var progressCache: [String: PlaybackProgress] = [:]
/// Server-progress that's newer than local but we haven't applied yet because
/// playback is active/paused. Offered via alert on next play; cleared on
/// item change.
var pendingServerProgress: PlaybackProgress?
private var syncTimer: Timer? private var syncTimer: Timer?
private var pullTimer: Timer?
private var lastReportedSecond: Double = -10 private var lastReportedSecond: Double = -10
private var lastTrackedChapterId: Int? private var lastPushedAt: Date = .distantPast
init() { init() {
let auth = AuthStore() let auth = AuthStore()
@@ -38,6 +44,25 @@ final class AppState {
self.player = PlayerEngine() self.player = PlayerEngine()
self.history = HistoryManager() self.history = HistoryManager()
self.bookmarks = BookmarkManager() self.bookmarks = BookmarkManager()
// Route lockscreen/Control-Center seeks through AppState so history is
// recorded otherwise remote skips bypass history entirely.
self.player.onRemoteSkip = { [weak self] seconds in
self?.skip(by: seconds)
}
self.player.onRemoteSeek = { [weak self] target in
self?.seekAbsolute(target)
}
// PlayerEngine reports chapter transitions from the AVPlayer time
// observer fires reliably in background/locked, unlike the 5s Timer.
self.player.onChapterChanged = { [weak self] chapter in
self?.recordChapterEntry(chapter)
}
}
private func recordChapterEntry(_ chapter: Chapter) {
guard let item = currentItem,
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
history.record(item: item, position: chapter.start, chapters: item.chapters)
} }
func bootstrap() async { func bootstrap() async {
@@ -60,6 +85,59 @@ final class AppState {
await refreshProgressCache() await refreshProgressCache()
} }
} }
startPullTimer()
}
/// Called by ContentView on scenePhase == .active. Immediate pull so we
/// notice updates from other devices the moment the app comes forward.
func onScenePhaseActive() {
Task { await pullAndReconcile() }
}
private func startPullTimer() {
pullTimer?.invalidate()
let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
Task { @MainActor [weak self] in await self?.pullAndReconcile() }
}
RunLoop.main.add(timer, forMode: .common)
pullTimer = timer
}
/// Pulls server progress and reconciles against local state:
/// - currentItem == nil: just refresh the cache (library covers).
/// - server is newer than local: stash for the resume-prompt.
/// - server is older than local: push our state immediately.
func pullAndReconcile() async {
guard network.isOnline, auth.isLoggedIn else { return }
await refreshProgressCache()
guard let current = currentItem else { return }
guard let server = progressCache[current.syncKey] else { return }
let local = player.absoluteCurrentTime
let positionDelta = abs(server.currentTime - local)
// Treat <8 s delta as identical to absorb own-update echoes, clock skew,
// and reporting granularity.
guard positionDelta > 8 else { return }
let serverIsNewer = server.updatedAt > lastPushedAt.addingTimeInterval(5)
if serverIsNewer {
pendingServerProgress = server
} else {
reportProgress(force: true)
}
}
func acceptPendingServerProgress() {
guard let p = pendingServerProgress else { return }
pendingServerProgress = nil
player.seekAbsolute(p.currentTime)
player.play()
}
func dismissPendingServerProgress() {
pendingServerProgress = nil
player.play()
} }
/// Pulls the entire progress map from the server (via /api/me). /// Pulls the entire progress map from the server (via /api/me).
@@ -95,6 +173,8 @@ final class AppState {
} }
func play(item: LibraryItem, overrideStartAt: Double? = nil) async { func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
// Clear any stash from a previous item only carry stashes per-item.
pendingServerProgress = nil
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady { if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() } if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
return return
@@ -109,10 +189,11 @@ final class AppState {
defer { isPreparingPlayback = false } defer { isPreparingPlayback = false }
var workItem = item var workItem = item
// Always fetch detail for books to get chapters; skip if downloaded offline. // Always fetch detail when online so chapters are loaded also for
// already-downloaded items (the persisted DownloadedItem doesn't store
// chapter metadata, so streaming the detail is the only source).
if !workItem.isPodcast && network.isOnline { if !workItem.isPodcast && network.isOnline {
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey) if let detail = try? await client.fetchItemDetail(itemId: item.id) {
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
workItem = detail workItem = detail
} }
} }
@@ -140,8 +221,6 @@ final class AppState {
if player.errorMessage == nil { if player.errorMessage == nil {
player.play() player.play()
startSyncTimer() startSyncTimer()
let startingChapter = workItem.chapters.last { $0.start <= startAt }
lastTrackedChapterId = startingChapter?.id
if UserDefaults.standard.bool(forKey: "historyEnabled") { if UserDefaults.standard.bool(forKey: "historyEnabled") {
history.record(item: workItem, position: startAt, chapters: workItem.chapters) history.record(item: workItem, position: startAt, chapters: workItem.chapters)
} }
@@ -182,8 +261,8 @@ final class AppState {
syncTimer = nil syncTimer = nil
player.teardown() player.teardown()
currentItem = nil currentItem = nil
pendingServerProgress = nil
lastReportedSecond = -10 lastReportedSecond = -10
lastTrackedChapterId = nil
} }
func togglePlay() { func togglePlay() {
@@ -193,7 +272,10 @@ final class AppState {
} }
func skip(by seconds: Double) { func skip(by seconds: Double) {
guard currentItem != nil else { return } guard let item = currentItem else { return }
if UserDefaults.standard.bool(forKey: "historyEnabled") {
history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
}
player.skip(by: seconds) player.skip(by: seconds)
reportProgress(force: true) reportProgress(force: true)
} }
@@ -238,21 +320,10 @@ final class AppState {
syncTimer = timer syncTimer = timer
} }
private func detectChapterChange(item: LibraryItem) {
let currentId = player.currentChapter?.id
guard currentId != lastTrackedChapterId else { return }
defer { lastTrackedChapterId = currentId }
guard lastTrackedChapterId != nil,
let chapter = player.currentChapter,
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
history.record(item: item, position: chapter.start, chapters: item.chapters)
}
private func reportProgress(force: Bool) { private func reportProgress(force: Bool) {
guard let item = currentItem else { return } guard let item = currentItem else { return }
let t = player.absoluteCurrentTime let t = player.absoluteCurrentTime
let d = player.totalDuration let d = player.totalDuration
detectChapterChange(item: item)
guard d > 0 else { return } guard d > 0 else { return }
if !force && abs(t - lastReportedSecond) < 3 { return } if !force && abs(t - lastReportedSecond) < 3 { return }
lastReportedSecond = t lastReportedSecond = t
@@ -265,6 +336,7 @@ final class AppState {
duration: d, duration: d,
isFinished: finished isFinished: finished
) )
lastPushedAt = Date()
Task { Task {
await sync.report( await sync.report(

View File

@@ -76,6 +76,11 @@ final class DownloadManager: @unchecked Sendable {
/// Bytes received for the currently in-flight track per downloadKey. /// Bytes received for the currently in-flight track per downloadKey.
/// Reset between tracks; cleared when the download finishes/cancels. /// Reset between tracks; cleared when the download finishes/cancels.
private(set) var inFlightBytes: [String: Int64] = [:] private(set) var inFlightBytes: [String: Int64] = [:]
/// Current track index (0-based) per active download. Set at the start of
/// each track iteration, cleared via defer when the download exits.
private var currentTrackIndex: [String: Int] = [:]
/// Total number of tracks per active download.
private var totalTrackCount: [String: Int] = [:]
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
private var activeTasks: [String: Task<Void, Never>] = [:] private var activeTasks: [String: Task<Void, Never>] = [:]
@@ -118,6 +123,21 @@ final class DownloadManager: @unchecked Sendable {
} }
} }
/// Called from the URL session delegate with the fraction (01) of the
/// currently downloading track. Combines with the completed-track count to
/// drive a smooth overall progress ring, even for single-track downloads.
nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) {
Task { @MainActor [self] in
guard let idx = self.currentTrackIndex[downloadKey],
let total = self.totalTrackCount[downloadKey],
total > 0 else { return }
let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total)
// Clamp below 1.0 so `performDownload` is the only place that
// transitions to `.downloaded` after the final track is persisted.
self.states[downloadKey] = .downloading(progress: min(0.999, overall))
}
}
private func fileSize(at url: URL) -> Int64 { private func fileSize(at url: URL) -> Int64 {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 } guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
if let n = attrs[.size] as? NSNumber { return n.int64Value } if let n = attrs[.size] as? NSNumber { return n.int64Value }
@@ -218,6 +238,8 @@ final class DownloadManager: @unchecked Sendable {
defer { defer {
pendingItems.removeValue(forKey: downloadKey) pendingItems.removeValue(forKey: downloadKey)
inFlightBytes.removeValue(forKey: downloadKey) inFlightBytes.removeValue(forKey: downloadKey)
currentTrackIndex.removeValue(forKey: downloadKey)
totalTrackCount.removeValue(forKey: downloadKey)
} }
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
do { do {
@@ -229,6 +251,7 @@ final class DownloadManager: @unchecked Sendable {
var tracks: [DownloadedTrack] = [] var tracks: [DownloadedTrack] = []
let total = max(workItem.audioFiles.count, 1) let total = max(workItem.audioFiles.count, 1)
totalTrackCount[downloadKey] = total
for (idx, file) in workItem.audioFiles.enumerated() { for (idx, file) in workItem.audioFiles.enumerated() {
if Task.isCancelled { if Task.isCancelled {
@@ -238,6 +261,7 @@ final class DownloadManager: @unchecked Sendable {
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue } guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
var request = URLRequest(url: url) var request = URLRequest(url: url)
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) } for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
currentTrackIndex[downloadKey] = idx
let tempURL: URL let tempURL: URL
do { do {
@@ -418,6 +442,10 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
totalBytesExpectedToWrite: Int64 totalBytesExpectedToWrite: Int64
) { ) {
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey) manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
if totalBytesExpectedToWrite > 0 {
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
manager._reportTrackByteFraction(fraction, for: downloadKey)
}
} }
func urlSession( func urlSession(

View File

@@ -33,6 +33,17 @@ final class PlayerEngine {
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit. /// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
var sleepRemainingSeconds: Double = 0 var sleepRemainingSeconds: Double = 0
var chapters: [Chapter] = [] var chapters: [Chapter] = []
/// Set by AppState. Fired when a remote/system control (lockscreen, Control
/// Center, headphones) triggers a skip, so AppState can record history and
/// run its own seek path instead of bypassing it.
var onRemoteSkip: ((Double) -> Void)?
/// Set by AppState. Fired when a remote control changes playback position.
var onRemoteSeek: ((Double) -> Void)?
/// Set by AppState. Fired by the AVPlayer periodic time observer whenever
/// playback crosses into a new chapter works reliably in background and
/// with the device locked, unlike a runloop-scheduled Timer.
var onChapterChanged: ((Chapter) -> Void)?
private var lastObservedChapterId: Int?
var currentChapter: Chapter? { var currentChapter: Chapter? {
chapters.last { $0.start <= absoluteCurrentTime } chapters.last { $0.start <= absoluteCurrentTime }
@@ -125,6 +136,9 @@ final class PlayerEngine {
} }
self.chapters = item.chapters self.chapters = item.chapters
// Reset chapter tracking first observation after load is "initial",
// not a transition.
lastObservedChapterId = nil
currentTitle = item.title currentTitle = item.title
currentAuthor = item.author currentAuthor = item.author
currentCoverURL = client.coverURL(itemId: item.id) currentCoverURL = client.coverURL(itemId: item.id)
@@ -251,6 +265,19 @@ final class PlayerEngine {
if wasPlaying != isPlaying { updateNowPlayingInfo() } if wasPlaying != isPlaying { updateNowPlayingInfo() }
updateEndOfBookSleep() updateEndOfBookSleep()
updateEndOfChapterSleep() updateEndOfChapterSleep()
detectChapterTransition()
}
private func detectChapterTransition() {
let currentId = currentChapter?.id
guard currentId != lastObservedChapterId else { return }
let priorId = lastObservedChapterId
lastObservedChapterId = currentId
// Skip the very first observation after load (priorId == nil) that's
// the initial position, not a real transition. AppState records that
// separately in `play(item:)`.
guard priorId != nil, let chapter = currentChapter else { return }
onChapterChanged?(chapter)
} }
func teardown() { func teardown() {
@@ -275,6 +302,7 @@ final class PlayerEngine {
errorMessage = nil errorMessage = nil
isSeeking = false isSeeking = false
chapters = [] chapters = []
lastObservedChapterId = nil
currentTitle = "" currentTitle = ""
currentAuthor = "" currentAuthor = ""
currentCoverURL = nil currentCoverURL = nil
@@ -374,12 +402,24 @@ final class PlayerEngine {
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds()) applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
center.skipForwardCommand.addTarget { [weak self] _ in center.skipForwardCommand.addTarget { [weak self] _ in
let s = Double(Self.currentSkipSeconds()) let s = Double(Self.currentSkipSeconds())
Task { @MainActor in self?.skip(by: s) } Task { @MainActor in
if let handler = self?.onRemoteSkip {
handler(s)
} else {
self?.skip(by: s)
}
}
return .success return .success
} }
center.skipBackwardCommand.addTarget { [weak self] _ in center.skipBackwardCommand.addTarget { [weak self] _ in
let s = Double(Self.currentSkipSeconds()) let s = Double(Self.currentSkipSeconds())
Task { @MainActor in self?.skip(by: -s) } Task { @MainActor in
if let handler = self?.onRemoteSkip {
handler(-s)
} else {
self?.skip(by: -s)
}
}
return .success return .success
} }
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -394,7 +434,13 @@ final class PlayerEngine {
return .commandFailed return .commandFailed
} }
let target = posEvent.positionTime let target = posEvent.positionTime
Task { @MainActor in self?.seekAbsolute(target) } Task { @MainActor in
if let handler = self?.onRemoteSeek {
handler(target)
} else {
self?.seekAbsolute(target)
}
}
return .success return .success
} }
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]

View File

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

View File

@@ -8,6 +8,7 @@ struct PlayerBar: View {
@State private var scrubValue: Double = 0 @State private var scrubValue: Double = 0
@State private var showDetails: Bool = false @State private var showDetails: Bool = false
@State private var showFullHistory: Bool = false @State private var showFullHistory: Bool = false
@State private var showResumePrompt: Bool = false
var body: some View { var body: some View {
if let item = app.currentItem { if let item = app.currentItem {
@@ -32,6 +33,16 @@ struct PlayerBar: View {
FullHistoryView() FullHistoryView()
.environment(app) .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 { } else if app.isPreparingPlayback {
VStack(spacing: 0) { VStack(spacing: 0) {
Divider() Divider()
@@ -64,7 +75,7 @@ struct PlayerBar: View {
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
Button { app.togglePlay() } label: { Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
} }
@@ -173,7 +184,7 @@ struct PlayerBar: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(!app.player.isReady) .disabled(!app.player.isReady)
Button { app.togglePlay() } label: { Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 34)) .font(.system(size: 34))
} }
@@ -231,41 +242,7 @@ struct PlayerBar: View {
} }
private var scrubber: some View { private var scrubber: some View {
VStack(spacing: 2) { ScrubberView(scrubbing: $scrubbing, scrubValue: $scrubValue)
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 detailsButtonVisible: Bool { private var detailsButtonVisible: Bool {
@@ -356,18 +333,20 @@ struct PlayerBar: View {
} }
} }
private var sleepEndsAt: Date {
Date().addingTimeInterval(app.player.sleepRemainingSeconds)
}
private var endOfPlaybackLabel: String { private var endOfPlaybackLabel: String {
app.currentItem?.isPodcast == true app.currentItem?.isPodcast == true
? "Bis Ende der Folge" ? "Bis Ende der Folge"
: "Bis Ende des Hörbuchs" : "Bis Ende des Hörbuchs"
} }
private func formatWallTime(_ date: Date) -> String { /// Intercept Play to surface the resume-prompt if another device pushed a
date.formatted(.dateTime.hour().minute()) /// 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 { private var rateMenu: some View {
@@ -431,3 +410,71 @@ struct PlayerBar: View {
return String(format: "%d:%02d", m, s) 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)
}
}