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:
@@ -287,7 +287,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MARKETING_VERSION = 2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||
PRODUCT_NAME = "ABS Client";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -341,7 +341,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MARKETING_VERSION = 2.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||
PRODUCT_NAME = "ABS Client";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 726 KiB |
@@ -167,10 +167,15 @@ final class ABSClient {
|
||||
currentTime: dto.currentTime ?? 0,
|
||||
duration: dto.duration ?? 0,
|
||||
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 {
|
||||
let body: [String: Any] = [
|
||||
"currentTime": progress.currentTime,
|
||||
@@ -200,7 +205,7 @@ final class ABSClient {
|
||||
currentTime: p.currentTime ?? 0,
|
||||
duration: p.duration ?? 0,
|
||||
isFinished: p.isFinished ?? false,
|
||||
updatedAt: Date()
|
||||
updatedAt: Self.parseLastUpdate(p.lastUpdate)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,15 @@ final class AppState {
|
||||
/// Used to show progress bars on covers in the library views.
|
||||
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 pullTimer: Timer?
|
||||
private var lastReportedSecond: Double = -10
|
||||
private var lastTrackedChapterId: Int?
|
||||
private var lastPushedAt: Date = .distantPast
|
||||
|
||||
init() {
|
||||
let auth = AuthStore()
|
||||
@@ -38,6 +44,25 @@ final class AppState {
|
||||
self.player = PlayerEngine()
|
||||
self.history = HistoryManager()
|
||||
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 {
|
||||
@@ -60,6 +85,59 @@ final class AppState {
|
||||
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).
|
||||
@@ -95,6 +173,8 @@ final class AppState {
|
||||
}
|
||||
|
||||
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 let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
|
||||
return
|
||||
@@ -109,10 +189,11 @@ final class AppState {
|
||||
defer { isPreparingPlayback = false }
|
||||
|
||||
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 {
|
||||
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
|
||||
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||
if let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||
workItem = detail
|
||||
}
|
||||
}
|
||||
@@ -140,8 +221,6 @@ final class AppState {
|
||||
if player.errorMessage == nil {
|
||||
player.play()
|
||||
startSyncTimer()
|
||||
let startingChapter = workItem.chapters.last { $0.start <= startAt }
|
||||
lastTrackedChapterId = startingChapter?.id
|
||||
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
||||
history.record(item: workItem, position: startAt, chapters: workItem.chapters)
|
||||
}
|
||||
@@ -182,8 +261,8 @@ final class AppState {
|
||||
syncTimer = nil
|
||||
player.teardown()
|
||||
currentItem = nil
|
||||
pendingServerProgress = nil
|
||||
lastReportedSecond = -10
|
||||
lastTrackedChapterId = nil
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@@ -193,7 +272,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
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)
|
||||
reportProgress(force: true)
|
||||
}
|
||||
@@ -238,21 +320,10 @@ final class AppState {
|
||||
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) {
|
||||
guard let item = currentItem else { return }
|
||||
let t = player.absoluteCurrentTime
|
||||
let d = player.totalDuration
|
||||
detectChapterChange(item: item)
|
||||
guard d > 0 else { return }
|
||||
if !force && abs(t - lastReportedSecond) < 3 { return }
|
||||
lastReportedSecond = t
|
||||
@@ -265,6 +336,7 @@ final class AppState {
|
||||
duration: d,
|
||||
isFinished: finished
|
||||
)
|
||||
lastPushedAt = Date()
|
||||
|
||||
Task {
|
||||
await sync.report(
|
||||
|
||||
@@ -76,6 +76,11 @@ final class DownloadManager: @unchecked Sendable {
|
||||
/// Bytes received for the currently in-flight track per downloadKey.
|
||||
/// Reset between tracks; cleared when the download finishes/cancels.
|
||||
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 activeTasks: [String: Task<Void, Never>] = [:]
|
||||
@@ -118,6 +123,21 @@ final class DownloadManager: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Called from the URL session delegate with the fraction (0…1) 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 {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
|
||||
if let n = attrs[.size] as? NSNumber { return n.int64Value }
|
||||
@@ -218,6 +238,8 @@ final class DownloadManager: @unchecked Sendable {
|
||||
defer {
|
||||
pendingItems.removeValue(forKey: downloadKey)
|
||||
inFlightBytes.removeValue(forKey: downloadKey)
|
||||
currentTrackIndex.removeValue(forKey: downloadKey)
|
||||
totalTrackCount.removeValue(forKey: downloadKey)
|
||||
}
|
||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
||||
do {
|
||||
@@ -229,6 +251,7 @@ final class DownloadManager: @unchecked Sendable {
|
||||
|
||||
var tracks: [DownloadedTrack] = []
|
||||
let total = max(workItem.audioFiles.count, 1)
|
||||
totalTrackCount[downloadKey] = total
|
||||
|
||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
||||
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 }
|
||||
var request = URLRequest(url: url)
|
||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
||||
currentTrackIndex[downloadKey] = idx
|
||||
|
||||
let tempURL: URL
|
||||
do {
|
||||
@@ -418,6 +442,10 @@ private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelega
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
|
||||
if totalBytesExpectedToWrite > 0 {
|
||||
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
manager._reportTrackByteFraction(fraction, for: downloadKey)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
|
||||
@@ -33,6 +33,17 @@ final class PlayerEngine {
|
||||
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
|
||||
var sleepRemainingSeconds: Double = 0
|
||||
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? {
|
||||
chapters.last { $0.start <= absoluteCurrentTime }
|
||||
@@ -125,6 +136,9 @@ final class PlayerEngine {
|
||||
}
|
||||
|
||||
self.chapters = item.chapters
|
||||
// Reset chapter tracking — first observation after load is "initial",
|
||||
// not a transition.
|
||||
lastObservedChapterId = nil
|
||||
currentTitle = item.title
|
||||
currentAuthor = item.author
|
||||
currentCoverURL = client.coverURL(itemId: item.id)
|
||||
@@ -251,6 +265,19 @@ final class PlayerEngine {
|
||||
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
||||
updateEndOfBookSleep()
|
||||
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() {
|
||||
@@ -275,6 +302,7 @@ final class PlayerEngine {
|
||||
errorMessage = nil
|
||||
isSeeking = false
|
||||
chapters = []
|
||||
lastObservedChapterId = nil
|
||||
currentTitle = ""
|
||||
currentAuthor = ""
|
||||
currentCoverURL = nil
|
||||
@@ -374,12 +402,24 @@ final class PlayerEngine {
|
||||
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
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
|
||||
}
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
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
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -394,7 +434,13 @@ final class PlayerEngine {
|
||||
return .commandFailed
|
||||
}
|
||||
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
|
||||
}
|
||||
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
@@ -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