import SwiftUI /// The bar along the bottom, in Glass. /// /// It draws what Playback knows, and Playback is the same object CarPlay and the lock screen /// drive, so all three agree without anything being kept in step by hand. struct PlayerBarView: View { @ObservedObject var playback: Playback /// Dragging the scrubber has to win over the clock, or the thumb fights the finger. @State private var scrubbing: Double? private var item: Playback.Item? { playback.nowPlaying } private var length: Double { playback.length ?? 0 } private var position: Double { scrubbing ?? playback.elapsed } var body: some View { if let item { VStack(spacing: 6) { HStack(spacing: 12) { art titles(item) Spacer(minLength: 8) controls close } scrubber } .padding(.horizontal, 14) .padding(.vertical, 9) .glassPane() .overlay(alignment: .top) { Rectangle().fill(Glass.line).frame(height: 0.5) } .transition(.move(edge: .bottom).combined(with: .opacity)) } } // MARK: - pieces @ViewBuilder private var art: some View { Group { if let image = playback.artwork { Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) } else { // The feed's initials, as the page does when there is no picture: two letters // read as a thing on purpose, where a grey square reads as something missing. ZStack { Rectangle().fill(Glass.accent.opacity(0.18)) Text(initials).font(.caption.weight(.semibold)).foregroundStyle(Glass.accent) } } } .frame(width: 38, height: 38) .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) } private var initials: String { let words = (item?.feedTitle ?? "").split(separator: " ").prefix(2) let letters = words.compactMap { $0.first }.map(String.init).joined() return letters.isEmpty ? "?" : letters.uppercased() } private func titles(_ item: Playback.Item) -> some View { VStack(alignment: .leading, spacing: 1) { Text(item.title.isEmpty ? "(untitled)" : item.title) .font(.subheadline.weight(.semibold)) .foregroundStyle(Glass.text) .lineLimit(1) Text(item.feedTitle) .font(.caption) .foregroundStyle(Glass.dim) .lineLimit(1) } } private var controls: some View { HStack(spacing: 14) { // 15 back and 30 forward, the same pair the page and the lock screen use, so every // way of skipping moves by the same amount. button("gobackward.15", "Back 15 seconds") { playback.seek(to: playback.elapsed - 15) } Button { playback.playing ? playback.pause() : playback.play() } label: { Image(systemName: playback.playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 30)) .symbolRenderingMode(.hierarchical) } .buttonStyle(.plain) .foregroundStyle(Glass.accent) .accessibilityLabel(playback.playing ? "Pause" : "Play") button("goforward.30", "Forward 30 seconds") { playback.seek(to: playback.elapsed + 30) } } } private var close: some View { button("xmark", "Close the player") { playback.stop() } } private func button(_ symbol: String, _ label: String, _ action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: symbol).font(.system(size: 15, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(Glass.dim) .accessibilityLabel(label) } private var scrubber: some View { HStack(spacing: 8) { Text(clock(position)).monospacedDigit() Slider(value: Binding( get: { position }, set: { scrubbing = $0 } ), in: 0...max(length, 1), onEditingChanged: { editing in // Seek when the finger lifts, not continuously: seeking on every value sends a // stream of them at a player that is still answering the last one. if !editing, let to = scrubbing { playback.seek(to: to) scrubbing = nil } }) .disabled(length <= 0) .tint(Glass.accent) Text(clock(length)).monospacedDigit() } .font(.caption2) .foregroundStyle(Glass.faint) } /// h:mm:ss once past an hour, m:ss below it, as the page's clock() does. private func clock(_ seconds: Double) -> String { guard seconds.isFinite, seconds >= 0 else { return "0:00" } let total = Int(seconds) let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60) return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) } }