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) } }