diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj index b54ee28..c041529 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -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; diff --git a/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png index 7e205d8..02a462e 100644 Binary files a/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/ABS Client/Audiobookshelf swift/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift index 2dfe588..419fe7f 100644 --- a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift +++ b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift @@ -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) ) } } diff --git a/ABS Client/Audiobookshelf swift/Services/AppState.swift b/ABS Client/Audiobookshelf swift/Services/AppState.swift index 9a2836a..e1252a1 100644 --- a/ABS Client/Audiobookshelf swift/Services/AppState.swift +++ b/ABS Client/Audiobookshelf swift/Services/AppState.swift @@ -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( diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift index d367b7e..9e1623f 100644 --- a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift @@ -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] = [:] @@ -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( diff --git a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift index 6657577..f644820 100644 --- a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift +++ b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift @@ -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] diff --git a/ABS Client/Audiobookshelf swift/Views/ContentView.swift b/ABS Client/Audiobookshelf swift/Views/ContentView.swift index cda528f..0962277 100644 --- a/ABS Client/Audiobookshelf swift/Views/ContentView.swift +++ b/ABS Client/Audiobookshelf swift/Views/ContentView.swift @@ -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 diff --git a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift index c65b0bf..e44c488 100644 --- a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift +++ b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift @@ -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) + } +}