Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift
2026-05-25 18:43:16 +02:00

353 lines
12 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
@State private var scrubbing: Bool = false
@State private var scrubValue: Double = 0
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))
} 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 { app.togglePlay() } 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()
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)
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 { app.togglePlay() } 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 {
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 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)
} 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 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())
}
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)
}
}