Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
Scarriffle 9497c6e315 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>
2026-05-27 20:44:38 +02:00

481 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct PlayerBar: View {
@Environment(AppState.self) private var app
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
@State private var scrubbing: Bool = false
@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 {
VStack(spacing: 0) {
Divider()
content(item: item)
.padding(.horizontal, 16)
#if os(iOS)
.padding(.top, 8)
.padding(.bottom, 10)
#else
.padding(.vertical, 10)
#endif
.background(.bar)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.sheet(isPresented: $showDetails) {
PlaybackDetailsView()
.environment(app)
}
.sheet(isPresented: $showFullHistory) {
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()
HStack(spacing: 12) {
ProgressView()
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
}
}
}
// MARK: - Platform layouts
#if os(iOS)
@ViewBuilder
private func content(item: LibraryItem) -> some View {
VStack(spacing: 8) {
// Header row: cover, title/author, play button
HStack(spacing: 12) {
cover(item: item)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.subheadline).bold().lineLimit(1)
Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let err = app.player.errorMessage {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
}
Spacer(minLength: 0)
Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
scrubber
HStack(spacing: 20) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Spacer()
if historyEnabled {
historyQuickMenu
}
Button { showDetails = true } label: {
Image(systemName: "list.bullet.indent")
.font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
sleepMenu
rateMenu
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.top, 2)
}
}
#else
@ViewBuilder
private func content(item: LibraryItem) -> some View {
HStack(spacing: 14) {
cover(item: item)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.subheadline).bold().lineLimit(1)
Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let err = app.player.errorMessage {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
}
.frame(minWidth: 160, idealWidth: 200, maxWidth: 240, alignment: .leading)
transportControls
scrubber
.frame(minWidth: 200)
rateMenu
sleepMenu
Spacer(minLength: 0)
if historyEnabled {
historyQuickMenu
}
Button { showDetails = true } label: {
Image(systemName: "list.bullet.indent")
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
.help("Kapitel & Lesezeichen")
statusIndicator
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Wiedergabe beenden")
}
}
private var transportControls: some View {
HStack(spacing: 14) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { handlePlayTap() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 34))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
.keyboardShortcut(.space, modifiers: [])
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
}
private var statusIndicator: some View {
HStack(spacing: 4) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 6, height: 6)
if app.sync.queuedCount > 0 {
Text("\(app.sync.queuedCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.help(app.network.isOnline
? "Online Fortschritt wird synchronisiert"
: "Offline \(app.sync.queuedCount) Eintrag/Einträge wartend")
}
#endif
// MARK: - Shared subviews
private func cover(item: LibraryItem) -> some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
if let img = phase.image {
img.resizable().aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.3)
}
}
} else {
Color.gray.opacity(0.3)
}
}
#if os(iOS)
.frame(width: 44, height: 44)
#else
.frame(width: 48, height: 48)
#endif
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var scrubber: some View {
ScrubberView(scrubbing: $scrubbing, scrubValue: $scrubValue)
}
private var detailsButtonVisible: Bool {
app.player.isReady
}
private var historyQuickMenu: some View {
Menu {
let recent = Array(app.history.entries
.filter { $0.itemId == app.currentItem?.id && $0.episodeId == app.currentItem?.episodeId }
.prefix(5))
if recent.isEmpty {
Text(String(localized: "history.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(recent) { entry in
Button {
app.seekAbsolute(entry.position)
} label: {
let timeStr = formatTime(entry.position)
let label = entry.chapterTitle.map { "\($0) · \(timeStr)" } ?? timeStr
Label(label, systemImage: "clock")
}
}
Divider()
}
Button {
showFullHistory = true
} label: {
Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath")
}
} label: {
Image(systemName: "clock.arrow.circlepath")
#if os(iOS)
.font(.system(size: 22))
#else
.font(.system(size: 16))
#endif
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
#endif
.menuIndicator(.hidden)
.help(String(localized: "player.history_recent"))
}
private var sleepMenu: some View {
Menu {
sleepOption(title: "Aus", mode: .off)
Divider()
sleepOption(title: "10 Minuten", mode: .minutes(10))
sleepOption(title: "20 Minuten", mode: .minutes(20))
sleepOption(title: "30 Minuten", mode: .minutes(30))
sleepOption(title: "1 Stunde", mode: .minutes(60))
sleepOption(title: endOfPlaybackLabel, mode: .endOfBook)
if !(app.currentItem?.chapters.isEmpty ?? true) {
sleepOption(title: "Bis Ende des Kapitels", mode: .endOfChapter)
}
} label: {
Image(systemName: app.player.sleepTimer == .off ? "moon.zzz" : "moon.zzz.fill")
#if os(iOS)
.font(.system(size: 22))
#else
.font(.system(size: 16))
#endif
.foregroundStyle(app.player.sleepTimer == .off ? Color.secondary : Color.accentColor)
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
#endif
.menuIndicator(.hidden)
.help("Sleep-Timer")
.disabled(!app.player.isReady)
}
@ViewBuilder
private func sleepOption(title: String, mode: SleepTimerMode) -> some View {
Button {
app.player.setSleepTimer(mode)
} label: {
if app.player.sleepTimer == mode {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
}
private var endOfPlaybackLabel: String {
app.currentItem?.isPodcast == true
? "Bis Ende der Folge"
: "Bis Ende des Hörbuchs"
}
/// 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 {
Menu {
ForEach([0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { r in
Button {
app.setRate(Float(r))
} label: {
HStack {
Text(String(format: "%.2g×", r))
if abs(Double(app.player.rate) - r) < 0.01 {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Text(String(format: "%.2g×", Double(app.player.rate)))
.font(.caption.monospacedDigit())
.padding(.horizontal, 10).padding(.vertical, 5)
.overlay(Capsule().stroke(Color.secondary.opacity(0.4)))
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
.help("Geschwindigkeit")
#endif
}
private var skipForwardImage: String {
switch skipSeconds {
case ...10: return "goforward.10"
case 11...15: return "goforward.15"
case 16...30: return "goforward.30"
case 31...45: return "goforward.45"
case 46...60: return "goforward.60"
default: return "goforward.90"
}
}
private var skipBackImage: String {
switch skipSeconds {
case ...10: return "gobackward.10"
case 11...15: return "gobackward.15"
case 16...30: return "gobackward.30"
case 31...45: return "gobackward.45"
case 46...60: return "gobackward.60"
default: return "gobackward.90"
}
}
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)
}
}
/// 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)
}
}