Files
libnovel/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift
Admin 57591766f2
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
iOS: update tab icons, skip interval, cover radius, and mini player progress bar fix
2026-03-08 19:23:14 +05:00

940 lines
39 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
import Kingfisher // used directly for blurred background in FullPlayerView
import AVKit // for AVRoutePickerView (AirPlay)
// MARK: - Mini player bar (pinned above tab bar)
struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
/// Live drag offset while the user is swiping up/down (negative = moving up).
@State private var dragOffset: CGFloat = 0
var body: some View {
ZStack {
// Static progress bar as background (full bleed behind content)
MiniPlayerProgressBar(progress: audioPlayer.progress)
// Content layer
HStack(spacing: 16) {
// Cover thumbnail with rounded corners
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 40))
}
.buttonStyle(.plain)
// Track info
VStack(alignment: .leading, spacing: 4) {
Text(chapterLabel)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.foregroundStyle(.primary)
Text(audioPlayer.bookTitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
Spacer(minLength: 8)
// Control buttons - compact group
HStack(spacing: 12) {
// Previous chapter button
if audioPlayer.status == .ready {
Button {
if let prev = audioPlayer.absolutePrevChapter {
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.absolutePrevChapter == nil)
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
}
// Status indicator or play/pause control
Group {
switch audioPlayer.status {
case .generating:
ProgressView()
.tint(.white)
.scaleEffect(1.0)
.frame(width: 44, height: 44)
case .ready:
MiniPlayerPlayPauseButton(
progress: audioPlayer.progress,
onToggle: { audioPlayer.togglePlayPause() }
)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
default:
EmptyView()
}
}
// Next chapter button
if audioPlayer.status == .ready {
Button {
if let next = audioPlayer.absoluteNextChapter {
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
ZStack {
Image(systemName: "forward.end.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
// Show small loading indicator if next chapter is being prefetched
if audioPlayer.nextPrefetchStatus == .prefetching {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
.scaleEffect(0.5)
.tint(.amber)
.padding(2)
.background(Circle().fill(.black.opacity(0.6)))
}
}
}
}
.frame(width: 40, height: 40)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.absoluteNextChapter == nil)
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.background(
// Dark rounded background (pill-shaped with full circular ends)
RoundedRectangle(cornerRadius: 40)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 40)
.fill(Color.black.opacity(0.3))
)
)
.frame(height: 20)
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
// Follow finger in both directions while dragging vertically
.offset(y: dragOffset)
// Visual feedback: fade out and scale down slightly when dragging down to dismiss
.opacity(dragOffset > 0 ? max(0.3, 1.0 - (dragOffset / 200)) : 1.0)
.scaleEffect(dragOffset > 0 ? max(0.95, 1.0 - (dragOffset / 800)) : 1.0)
.simultaneousGesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
// Only handle vertical drags (not horizontal seeks)
if abs(value.translation.height) > abs(value.translation.width) {
if value.translation.height < 0 {
// Upward swipe: rubberband resistance (opens full player)
dragOffset = value.translation.height * 0.4
} else {
// Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
}
}
}
.onEnded { value in
let velocity = value.predictedEndTranslation.height - value.translation.height
if value.translation.height < -40 || velocity < -200 {
// Swipe up: open full player
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
showFullPlayer = true
} else if value.translation.height > 60 || velocity > 200 {
// Swipe down: dismiss with animation
withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
dragOffset = 300 // Slide out completely
}
// Stop audio after animation starts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
audioPlayer.stop()
}
} else {
// Not enough distance: spring back
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
}
}
)
}
private var chapterLabel: String {
let raw = audioPlayer.chapterTitle.isEmpty
? "Chapter \(audioPlayer.chapter)"
: audioPlayer.chapterTitle
return raw.strippingTrailingDate()
}
}
// MARK: - Full player sheet
struct FullPlayerView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService
/// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {}
@State private var showingSpeedMenu = false
@State private var showingChaptersList = false
@State private var showingSleepTimer = false
var body: some View {
ZStack {
// Background: blurred cover art
GeometryReader { geo in
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.scaledToFill()
.frame(width: geo.size.width, height: geo.size.height)
.clipped()
.blur(radius: 40, opaque: true)
.overlay(Color.black.opacity(0.55))
.ignoresSafeArea()
}
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
// Drag handle pill visual cue that you can swipe down to close
Capsule()
.fill(Color.white.opacity(0.35))
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.bottom, 16)
// Cover art with watermark
ZStack(alignment: .bottomLeading) {
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.1))
.overlay(
Image(systemName: "book.closed")
.font(.system(size: 48))
.foregroundStyle(.white.opacity(0.4))
)
}
.scaledToFill()
.frame(width: 240, height: 240)
.clipShape(RoundedRectangle(cornerRadius: 18))
.shadow(color: .black.opacity(0.5), radius: 24, y: 12)
// Watermark (voice name from audio player)
Text(voiceName)
.font(.custom("Snell Roundhand", size: 20))
.foregroundStyle(.white.opacity(0.7))
.shadow(color: .black.opacity(0.4), radius: 2)
.padding(14)
}
.padding(.horizontal, 48)
// Title block
VStack(spacing: 4) {
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.lineLimit(2)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
}
.padding(.horizontal, 32)
.padding(.top, 20)
// Action buttons row + metadata inline
HStack(spacing: 0) {
Spacer()
// Metadata pill (only when ready)
if audioPlayer.status != .generating {
Text("\(yearText) · \(cacheStatusText) · OPUS")
.font(.caption2)
.foregroundStyle(.white.opacity(0.35))
.padding(.horizontal, 8)
}
Menu {
Button {
audioPlayer.autoNext.toggle()
} label: {
Label(
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next",
systemImage: audioPlayer.autoNext ? "checkmark" : ""
)
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 40, height: 40)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.top, 10)
// Seek bar hidden while generating
if audioPlayer.status != .generating {
PlayerProgressSection(
progress: audioPlayer.progress,
onSeek: { audioPlayer.seek(to: $0) }
)
} else {
// Generating state: compact progress indicator with label
VStack(spacing: 8) {
ProgressView()
.tint(.white.opacity(0.7))
.scaleEffect(1.1)
Text("Generating audio…")
.font(.caption)
.foregroundStyle(.white.opacity(0.5))
}
.frame(maxWidth: .infinity)
.padding(.top, 20)
.padding(.bottom, 4)
}
// Controls
HStack(spacing: 0) {
// skip back 15s
Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
// previous chapter
Button {
if let prev = audioPlayer.absolutePrevChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.absolutePrevChapter == nil)
.opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0)
// play / pause large circle button
PlayerPlayPauseButton(
progress: audioPlayer.progress,
isGenerating: audioPlayer.status == .generating,
onToggle: { audioPlayer.togglePlayPause() }
)
// next chapter
Button {
if let next = audioPlayer.absoluteNextChapter {
onDismiss()
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
ZStack {
Image(systemName: "forward.end.fill")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
if audioPlayer.nextPrefetchStatus == .prefetching {
VStack {
Spacer()
HStack {
Spacer()
ProgressView()
.scaleEffect(0.55)
.tint(.amber)
.padding(3)
.background(Circle().fill(.black.opacity(0.6)))
}
}
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.absoluteNextChapter == nil)
.opacity(audioPlayer.absoluteNextChapter == nil ? 0.4 : 1.0)
// skip forward 15s
Button { audioPlayer.skip(by: 15) } label: {
Image(systemName: "goforward.15")
.font(.system(size: 22, weight: .regular))
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 20)
// Bottom toolbar
HStack(spacing: 0) {
// AirPlay
AirPlayButton()
.frame(width: 22, height: 22)
.frame(maxWidth: .infinity)
// Speed control
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
Button {
audioPlayer.setSpeed(s)
} label: {
if s == audioPlayer.speed {
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark")
} else {
Text("\(s, specifier: "%.2g")×")
}
}
}
} label: {
// Show current speed as a badge instead of gear icon
Text("\(audioPlayer.speed, specifier: "%.2g")×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Collapse
Button { onDismiss() } label: {
Image(systemName: "chevron.down")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Queue (show chapters list)
Button { showingChaptersList = true } label: {
Image(systemName: "list.bullet")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
// Sleep timer
Button { showingSleepTimer = true } label: {
Image(systemName: sleepTimerIcon)
.font(.system(size: 22))
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
.ignoresSafeArea(edges: .bottom)
}
.sheet(isPresented: $showingChaptersList) {
ChaptersListSheet(
chapters: audioPlayer.chapters,
currentChapter: audioPlayer.chapter,
onChapterSelect: { selectedChapter in
showingChaptersList = false
guard selectedChapter != audioPlayer.chapter else { return }
let currentAudioChapter = audioPlayer.chapter
// Find the chapter metadata from the loaded list
let chapterTitle = audioPlayer.chapters
.first(where: { $0.number == selectedChapter })?.title ?? ""
let nextChapter = audioPlayer.chapters
.filter({ $0.number > selectedChapter })
.min(by: { $0.number < $1.number })?.number
let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil
// Load & start playing the selected chapter directly
audioPlayer.load(
slug: audioPlayer.slug,
chapter: selectedChapter,
chapterTitle: chapterTitle,
bookTitle: audioPlayer.bookTitle,
coverURL: audioPlayer.coverURL,
voice: audioPlayer.voice,
speed: audioPlayer.speed,
chapters: audioPlayer.chapters,
nextChapter: nextChapter,
prevChapter: prevChapter
)
// Also navigate the text reader if it's open
let notifName: Notification.Name = selectedChapter > currentAudioChapter
? .skipToNextChapter
: .skipToPrevChapter
let key = selectedChapter > currentAudioChapter ? "next" : "prev"
NotificationCenter.default.post(
name: notifName,
object: nil,
userInfo: [key: selectedChapter]
)
}
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showingSleepTimer) {
SleepTimerSheet(audioPlayer: audioPlayer)
.presentationDetents([.height(500)])
.presentationDragIndicator(.visible)
}
}
private var voiceName: String {
// Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella")
let components = audioPlayer.voice.split(separator: "_")
if components.count > 1 {
return String(components[1]).capitalized
}
return audioPlayer.voice.capitalized
}
private var cacheStatusText: String {
switch audioPlayer.status {
case .ready:
return "Cache"
case .generating:
return "Generating"
default:
return "Unknown"
}
}
private static let yearFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy"
return f
}()
private var yearText: String {
// TODO: Could fetch actual publication year from book metadata
// For now, return current year or placeholder
return Self.yearFormatter.string(from: Date())
}
private var sleepTimerIcon: String {
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz"
}
}
// MARK: - AirPlay Button
struct AirPlayButton: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
let vc = UIViewController()
vc.view.backgroundColor = .clear
let picker = AVRoutePickerView()
picker.tintColor = UIColor.white.withAlphaComponent(0.7)
picker.activeTintColor = UIColor(named: "AccentColor") ?? UIColor.systemOrange
picker.prioritizesVideoDevices = false
picker.translatesAutoresizingMaskIntoConstraints = false
vc.view.addSubview(picker)
NSLayoutConstraint.activate([
picker.leadingAnchor.constraint(equalTo: vc.view.leadingAnchor),
picker.trailingAnchor.constraint(equalTo: vc.view.trailingAnchor),
picker.topAnchor.constraint(equalTo: vc.view.topAnchor),
picker.bottomAnchor.constraint(equalTo: vc.view.bottomAnchor),
])
return vc
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
// MARK: - Sleep Timer Sheet
struct SleepTimerSheet: View {
@ObservedObject var audioPlayer: AudioPlayerService
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
Button {
audioPlayer.setSleepTimer(nil)
dismiss()
} label: {
HStack {
Text("Off")
.foregroundStyle(.primary)
Spacer()
if audioPlayer.sleepTimer == nil {
Image(systemName: "checkmark")
.foregroundStyle(.amber)
}
}
}
} header: {
Text("Chapter-based")
}
Section {
ForEach([1, 2, 3, 4], id: \.self) { count in
Button {
audioPlayer.setSleepTimer(.chapters(count))
dismiss()
} label: {
HStack {
Text("\(count) \(count == 1 ? "chapter" : "chapters")")
.foregroundStyle(.primary)
Spacer()
if case .chapters(let c) = audioPlayer.sleepTimer, c == count {
Image(systemName: "checkmark")
.foregroundStyle(.amber)
}
}
}
}
}
Section {
ForEach([20, 40, 60, 120], id: \.self) { minutes in
Button {
audioPlayer.setSleepTimer(.minutes(minutes))
dismiss()
} label: {
HStack {
Text(formatTimerOption(minutes))
.foregroundStyle(.primary)
Spacer()
if case .minutes(let m) = audioPlayer.sleepTimer, m == minutes {
Image(systemName: "checkmark")
.foregroundStyle(.amber)
}
}
}
}
} header: {
Text("Time-based")
}
}
.navigationTitle("Sleep Timer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
private func formatTimerOption(_ minutes: Int) -> String {
if minutes < 60 {
return "\(minutes) mins"
} else {
let hours = minutes / 60
return "\(hours) \(hours == 1 ? "hour" : "hours")"
}
}
}
// MARK: - Chapters List Sheet
struct ChaptersListSheet: View {
let chapters: [ChapterIndexBrief]
let currentChapter: Int
let onChapterSelect: (Int) -> Void
@Environment(\.dismiss) private var dismiss
// Initialize scroll position to current chapter immediately (before view appears)
init(chapters: [ChapterIndexBrief], currentChapter: Int, onChapterSelect: @escaping (Int) -> Void) {
self.chapters = chapters
self.currentChapter = currentChapter
self.onChapterSelect = onChapterSelect
// Set initial scroll position state before view renders
_scrollPosition = State(initialValue: currentChapter)
}
@State private var scrollPosition: Int?
var body: some View {
NavigationStack {
List {
ForEach(chapters, id: \.number) { chapter in
Button {
onChapterSelect(chapter.number)
} label: {
HStack(spacing: 12) {
// Chapter number badge
Text("\(chapter.number)")
.font(.caption.bold())
.foregroundStyle(chapter.number == currentChapter ? .white : .secondary)
.frame(width: 44, height: 44)
.background(
Circle()
.fill(chapter.number == currentChapter ? Color.amber : Color.gray.opacity(0.2))
)
// Chapter title
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title.strippingTrailingDate())
.font(.subheadline.weight(chapter.number == currentChapter ? .semibold : .regular))
.foregroundStyle(chapter.number == currentChapter ? .primary : .primary)
.lineLimit(2)
if chapter.number == currentChapter {
Text("Now Playing")
.font(.caption2)
.foregroundStyle(.amber)
}
}
Spacer()
// Checkmark for current chapter
if chapter.number == currentChapter {
Image(systemName: "checkmark")
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding(.vertical, 8)
}
.buttonStyle(.plain)
.listRowBackground(
chapter.number == currentChapter
? Color.amber.opacity(0.1)
: Color.clear
)
.id(chapter.number)
}
}
.listStyle(.plain)
.scrollPosition(id: $scrollPosition, anchor: .center)
.navigationTitle("Chapters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
}
// MARK: - Custom seek slider
// A thicker, rounded-thumb slider that matches the amber design language.
struct PlayerSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
@State private var isDragging = false
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let fraction = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
let clampedFraction = max(0, min(1, fraction))
let filled = width * clampedFraction
let thumbSize: CGFloat = isDragging ? 22 : 22
let trackHeight: CGFloat = isDragging ? 5 : 4
ZStack(alignment: .leading) {
// Track
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: trackHeight)
// Fill
Capsule()
.fill(Color.amber)
.frame(width: max(filled, thumbSize / 2), height: trackHeight)
// Thumb
Circle()
.fill(Color.white)
.frame(width: thumbSize, height: thumbSize)
.shadow(color: .black.opacity(0.25), radius: 3, y: 1)
.offset(x: max(0, filled - thumbSize / 2))
.animation(.spring(response: 0.2), value: isDragging)
}
.frame(height: 28) // generous touch target
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
isDragging = true
let raw = drag.location.x / width
let clamped = max(0, min(1, raw))
value = range.lowerBound + clamped * (range.upperBound - range.lowerBound)
}
.onEnded { _ in
isDragging = false
}
)
}
.frame(height: 28)
}
}
// MARK: - Isolated mini-player progress bar background
private struct MiniPlayerProgressBar: View {
@ObservedObject var progress: PlaybackProgress
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 40)
.fill(Color.white.opacity(0.2))
RoundedRectangle(cornerRadius: 6)
.fill(Color.amber.opacity(0.3))
.frame(width: max(0, geo.size.width * fraction))
}
.clipShape(RoundedRectangle(cornerRadius: 40))
}
}
private var fraction: CGFloat {
guard progress.duration > 0 else { return 0 }
return CGFloat(progress.currentTime / progress.duration)
}
}
// MARK: - Isolated progress section (seek bar + timestamps)
// Observes PlaybackProgress directly so the 0.5-second time ticks only
// invalidate this small view not the menus or controls around it.
private struct PlayerProgressSection: View {
@ObservedObject var progress: PlaybackProgress
let onSeek: (Double) -> Void
var body: some View {
VStack(spacing: 4) {
PlayerSlider(
value: Binding(
get: { progress.currentTime },
set: { onSeek($0) }
),
range: 0...max(progress.duration, 1)
)
HStack {
Text(formatTime(progress.currentTime))
Spacer()
Text("-" + formatTime(progress.duration - progress.currentTime))
}
.font(.caption.monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
}
.padding(.horizontal, 28)
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let s = Int(seconds)
return "\(s / 60):\(String(format: "%02d", s % 60))"
}
}
// MARK: - Isolated play/pause button
// Observes PlaybackProgress so isPlaying changes only re-render this button.
private struct PlayerPlayPauseButton: View {
@ObservedObject var progress: PlaybackProgress
let isGenerating: Bool
let onToggle: () -> Void
var body: some View {
Button { onToggle() } label: {
ZStack {
Circle()
.fill(.white.opacity(0.15))
.frame(width: 64, height: 64)
if isGenerating {
ProgressView()
.tint(.white)
.scaleEffect(1.2)
} else {
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 30, weight: .bold))
.foregroundStyle(.white)
.offset(x: progress.isPlaying ? 0 : 2)
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(isGenerating)
}
}
// MARK: - Isolated mini-player play/pause button
private struct MiniPlayerPlayPauseButton: View {
@ObservedObject var progress: PlaybackProgress
let onToggle: () -> Void
var body: some View {
Button { onToggle() } label: {
Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}