353 lines
12 KiB
Swift
353 lines
12 KiB
Swift
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)
|
||
}
|
||
}
|