Compare commits

..

1 Commits

Author SHA1 Message Date
Admin
3d9d8ab365 Align iOS player UI with new glass design
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
2026-03-07 23:45:40 +05:00
7 changed files with 179 additions and 452 deletions

View File

@@ -23,19 +23,19 @@ struct RootTabView: View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "books.vertical.fill") }
.tabItem { Label("Library", systemImage: "books.vertical") }
.tag(Tab.library)
BrowseView()
.tabItem { Label("Discover", systemImage: "globe") }
.tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "person.fill") }
.tabItem { Label("Profile", systemImage: "person.crop.circle") }
.tag(Tab.profile)
}
// Reserve space for the mini-player above the tab bar so scroll content
@@ -49,7 +49,7 @@ struct RootTabView: View {
// Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight)
.padding(.bottom, max(tabBarHeight - 6, 0))
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}

View File

@@ -35,6 +35,6 @@ extension String {
enum AppLayout {
/// Height of the persistent mini-player bar:
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
static let miniPlayerBarHeight: CGFloat = 92
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
static let miniPlayerBarHeight: CGFloat = 66
}

View File

@@ -33,7 +33,6 @@ final class AudioPlayerService: ObservableObject {
@Published var autoNext: Bool = false
@Published var nextChapter: Int? = nil
@Published var prevChapter: Int? = nil
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@@ -74,7 +73,7 @@ final class AudioPlayerService: ObservableObject {
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
func load(slug: String, chapter: Int, chapterTitle: String,
bookTitle: String, coverURL: String, voice: String, speed: Double,
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
chapters: [ChapterIndexBrief], nextChapter: Int?) {
generationTask?.cancel()
prefetchTask?.cancel()
stop()
@@ -88,7 +87,6 @@ final class AudioPlayerService: ObservableObject {
self.speed = speed
self.chapters = chapters
self.nextChapter = nextChapter
self.prevChapter = prevChapter
self.nextPrefetchStatus = .none
self.nextAudioURL = ""
self.nextPrefetchedChapter = nil
@@ -330,7 +328,6 @@ final class AudioPlayerService: ObservableObject {
let nextTitle = chapters.first(where: { $0.number == next })?.title ?? ""
let nextNextChapter = chapters.first(where: { $0.number > next })?.number
let nextPrevChapter: Int? = chapter // Current chapter becomes previous for the next one
// If we already prefetched a URL for the next chapter, skip straight to
// playback and kick off generation in the background for the one after.
@@ -341,7 +338,6 @@ final class AudioPlayerService: ObservableObject {
chapter = next
chapterTitle = nextTitle
nextChapter = nextNextChapter
prevChapter = nextPrevChapter
nextPrefetchStatus = .none
nextAudioURL = ""
nextPrefetchedChapter = nil
@@ -364,8 +360,7 @@ final class AudioPlayerService: ObservableObject {
voice: voice,
speed: speed,
chapters: chapters,
nextChapter: nextNextChapter,
prevChapter: nextPrevChapter
nextChapter: nextNextChapter
)
}
}
@@ -467,6 +462,4 @@ enum AudioPlayerStatus: Equatable {
extension Notification.Name {
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
static let skipToNextChapter = Notification.Name("skipToNextChapter")
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
}

View File

@@ -47,7 +47,6 @@ final class ChapterReaderViewModel: ObservableObject {
audioPlayer.togglePlayPause()
} else {
let nextChapter: Int? = content.next
let prevChapter: Int? = content.prev
audioPlayer.load(
slug: slug,
chapter: chapter,
@@ -57,8 +56,7 @@ final class ChapterReaderViewModel: ObservableObject {
voice: settings.voice,
speed: settings.speed,
chapters: content.chapters,
nextChapter: nextChapter,
prevChapter: prevChapter
nextChapter: nextChapter
)
}
}

View File

@@ -86,7 +86,7 @@ struct BookDetailView: View {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold()) 
.font(.caption.bold())
.foregroundStyle(.amber)
}
}

View File

@@ -61,14 +61,6 @@ struct ChapterReaderView: View {
vm.navigateTo = next
}
}
.onReceive(NotificationCenter.default.publisher(for: .skipToNextChapter)) { note in
guard let next = note.userInfo?["next"] as? Int else { return }
vm.navigateTo = next
}
.onReceive(NotificationCenter.default.publisher(for: .skipToPrevChapter)) { note in
guard let prev = note.userInfo?["prev"] as? Int else { return }
vm.navigateTo = prev
}
.navigationDestination(isPresented: Binding(
get: { vm.navigateTo != nil },
set: { if !$0 { vm.navigateTo = nil } }

View File

@@ -7,161 +7,125 @@ struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
private let capsuleRadius: CGFloat = 24
/// Live drag offset while the user is swiping up (negative = moving up).
@State private var dragOffset: CGFloat = 0
var body: some View {
HStack(spacing: 16) {
// Cover thumbnail with rounded corners
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
VStack(spacing: 6) {
// Progress line thin amber strip at the very top
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.12)).frame(height: 2)
Capsule()
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 2)
}
}
.buttonStyle(.plain)
.frame(height: 2)
// 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 }
HStack(spacing: 12) {
// Cover thumbnail
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 46, height: 46)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(.plain)
Spacer(minLength: 8)
// Track info
VStack(alignment: .leading, spacing: 2) {
Text(audioPlayer.bookTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(chapterLabel)
.font(.caption)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
// Control buttons
HStack(spacing: 16) {
// Status indicator or play/pause control
// Status indicator or playback control
Group {
switch audioPlayer.status {
case .generating:
ProgressView()
.tint(.white)
.scaleEffect(1.0)
.frame(width: 44, height: 44)
.tint(.amber)
.scaleEffect(0.85)
.frame(width: 34, height: 34)
case .ready:
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24, weight: .semibold))
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.frame(width: 34, height: 34)
.background(.white.opacity(0.12), in: Circle())
}
.buttonStyle(.plain)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 24))
.foregroundStyle(.red)
.frame(width: 44, height: 44)
.frame(width: 34, height: 34)
default:
EmptyView()
}
}
// Skip forward button
if audioPlayer.status == .ready {
Button {
if let next = audioPlayer.nextChapter {
// Navigate to next chapter
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.4 : 1.0)
// Dismiss
Button { audioPlayer.stop() } label: {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white.opacity(0.7))
.frame(width: 28, height: 28)
.background(.white.opacity(0.12), in: Circle())
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.frame(maxWidth: 520)
.background(
ZStack {
// Dark rounded background (pill-shaped with full circular ends)
RoundedRectangle(cornerRadius: 40)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 40)
.fill(Color.black.opacity(0.3))
)
// Progress indicator as bottom border
GeometryReader { geo in
VStack {
Spacer()
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: 1)
.fill(Color.white.opacity(0.2))
.frame(height: 3)
// Progress fill
RoundedRectangle(cornerRadius: 1)
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 3)
}
}
}
.padding(.bottom, 0)
}
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(Color.black.opacity(0.18))
)
.frame(height: 80)
.padding(.horizontal, 12)
.shadow(color: .black.opacity(0.3), radius: 12, y: 4)
// Follow finger in both directions while dragging
.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)
.overlay(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.overlay(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.stroke(Color.white.opacity(0.06), lineWidth: 1)
.blur(radius: 0.8)
)
.shadow(color: .black.opacity(0.25), radius: 16, y: 8)
// Follow finger upward while dragging, spring back or expand on release
.offset(y: min(dragOffset, 0))
.gesture(
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
// Only track upward swipes here; downward dismiss is on ended
if value.translation.height < 0 {
// Upward swipe: rubberband resistance (opens full player)
// Rubberband: feels lighter as you drag further
dragOffset = value.translation.height * 0.4
} else {
// Downward swipe: less resistance for easier dismiss
dragOffset = value.translation.height * 0.8
dragOffset = value.translation.height * 0.15
}
}
.onEnded { value in
let velocity = value.predictedEndTranslation.height - value.translation.height
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
dragOffset = 0
}
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
}
} else if value.translation.height > 40 || velocity > 200 {
audioPlayer.stop()
}
}
)
@@ -186,180 +150,83 @@ 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 isFavorited = false
@State private var isLiked = false
@State private var showingSpeedMenu = false
@State private var isShuffled = false
@State private var isRepeating = 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()
}
LinearGradient(
colors: [
Color(red: 73 / 255, green: 55 / 255, blue: 102 / 255),
Color(red: 34 / 255, green: 24 / 255, blue: 49 / 255)
],
startPoint: .top,
endPoint: .bottom
)
.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)
.frame(width: 46, height: 5)
.padding(.top, 12)
.padding(.bottom, 8)
.padding(.bottom, 14)
Spacer(minLength: 0)
// 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: 260, height: 260)
.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: 22))
.foregroundStyle(.white.opacity(0.7))
.shadow(color: .black.opacity(0.4), radius: 2)
.padding(16)
}
.padding(.horizontal, 48)
Spacer(minLength: 0)
// Cover art
KFImage(URL(string: audioPlayer.coverURL))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(.white.opacity(0.12))
.overlay(
Image(systemName: "music.note")
.font(.system(size: 52))
.foregroundStyle(.white.opacity(0.35))
)
}
.scaledToFill()
.frame(width: 310, height: 310)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 26, y: 16)
.padding(.horizontal, 32)
// Title block
VStack(spacing: 6) {
VStack(spacing: 8) {
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))
.font(.title3.weight(.regular))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
.padding(.horizontal, 32)
.padding(.top, 28)
// Action buttons row (like, favorite, menu)
HStack(spacing: 32) {
Spacer()
Button {
withAnimation(.spring(response: 0.3)) {
isLiked.toggle()
}
} label: {
Image(systemName: isLiked ? "heart.fill" : "heart")
.font(.system(size: 26))
.foregroundStyle(isLiked ? .pink : .white.opacity(0.7))
.frame(width: 44, height: 44)
.padding(.top, 22)
// Actions row
HStack(spacing: 22) {
Button {} label: {
Image(systemName: "heart")
.font(.system(size: 20, weight: .regular))
}
.buttonStyle(.plain)
Button {
withAnimation(.spring(response: 0.3)) {
isFavorited.toggle()
}
} label: {
Image(systemName: isFavorited ? "star.fill" : "star")
.font(.system(size: 26))
.foregroundStyle(isFavorited ? .amber : .white.opacity(0.7))
.frame(width: 44, height: 44)
Button {} label: {
Image(systemName: "star")
.font(.system(size: 20, weight: .regular))
}
.buttonStyle(.plain)
Menu {
Button {
// Share
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
// Add to library
} label: {
Label("Add to Library", systemImage: "plus")
}
Divider()
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: 26))
.foregroundStyle(.white.opacity(0.7))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.top, 12)
// Metadata row (year, cache status, format)
HStack(spacing: 8) {
Text(yearText)
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
if audioPlayer.status != .generating {
Text("")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text(cacheStatusText)
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
Text("")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text("OPUS")
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
Text("")
.font(.caption)
.foregroundStyle(.white.opacity(0.3))
Text("149 kbps")
.font(.caption)
.foregroundStyle(.white.opacity(0.4))
Button {} label: {
Image(systemName: "ellipsis")
.font(.system(size: 20, weight: .semibold))
}
}
.padding(.top, 4)
.foregroundStyle(.white.opacity(0.9))
.padding(.top, 18)
// Seek bar
VStack(spacing: 6) {
VStack(spacing: 8) {
PlayerSlider(
value: Binding(
get: { audioPlayer.currentTime },
@@ -370,172 +237,77 @@ struct FullPlayerView: View {
HStack {
Text(formatTime(audioPlayer.currentTime))
Spacer()
Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime))
Text("-\(formatTime(max(audioPlayer.duration - audioPlayer.currentTime, 0)))")
}
.font(.caption.monospacedDigit())
.foregroundStyle(.white.opacity(0.5))
.foregroundStyle(.white.opacity(0.65))
}
.padding(.horizontal, 28)
.padding(.top, 24)
.padding(.top, 18)
// Controls
HStack(spacing: 0) {
// skip back 15s
HStack(spacing: 26) {
Button { isShuffled.toggle() } label: {
Image(systemName: "shuffle")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isShuffled ? .white : .white.opacity(0.45))
}
Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
.foregroundStyle(.white)
}
.buttonStyle(.plain)
// previous chapter
Button {
if let prev = audioPlayer.prevChapter {
NotificationCenter.default.post(
name: .skipToPrevChapter,
object: nil,
userInfo: ["prev": prev]
)
}
} label: {
Image(systemName: "backward.end.fill")
.font(.system(size: 32, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.prevChapter == nil)
.opacity(audioPlayer.prevChapter == nil ? 0.5 : 1.0)
// play / pause
Button { audioPlayer.togglePlayPause() } label: {
ZStack {
Circle()
.fill(Color.white)
.frame(width: 72, height: 72)
.shadow(color: .black.opacity(0.3), radius: 10, y: 6)
if audioPlayer.status == .generating {
ProgressView()
.tint(.white)
.scaleEffect(1.3)
.tint(Color.black.opacity(0.7))
} else {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 38, weight: .bold))
.foregroundStyle(.white)
.offset(x: audioPlayer.isPlaying ? 0 : 3)
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(Color.black.opacity(0.85))
.offset(x: audioPlayer.isPlaying ? 0 : 2)
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.status == .generating)
// next chapter
Button {
if let next = audioPlayer.nextChapter {
NotificationCenter.default.post(
name: .skipToNextChapter,
object: nil,
userInfo: ["next": next]
)
}
} label: {
Image(systemName: "forward.end.fill")
.font(.system(size: 32, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(audioPlayer.nextChapter == nil)
.opacity(audioPlayer.nextChapter == nil ? 0.5 : 1.0)
// skip forward 15s
Button { audioPlayer.skip(by: 15) } label: {
Image(systemName: "goforward.15")
Button { audioPlayer.skip(by: 30) } label: {
Image(systemName: "goforward.30")
.font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9))
.frame(maxWidth: .infinity)
.foregroundStyle(.white)
}
Button { isRepeating.toggle() } label: {
Image(systemName: "repeat")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isRepeating ? .white : .white.opacity(0.45))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 20)
.padding(.top, 32)
.padding(.top, 18)
Spacer(minLength: 24)
// Bottom toolbar
HStack(spacing: 0) {
// AirPlay (placeholder)
Button {} label: {
Image(systemName: "airplayaudio")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Settings (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: {
Image(systemName: "gearshape.fill")
.font(.system(size: 22))
.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)
// Comments (placeholder)
Button {} label: {
Image(systemName: "text.bubble")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Queue (show chapters list)
Button {} label: {
Image(systemName: "list.bullet")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Sleep timer (placeholder)
Button {} label: {
Image(systemName: "moon.zzz")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.disabled(true)
// Utility row
HStack(spacing: 30) {
Button {} label: { Image(systemName: "airplayaudio") }
Button {} label: { Image(systemName: "gearshape") }
Button { onDismiss() } label: { Image(systemName: "chevron.down") }
Button {} label: { Image(systemName: "quote.bubble") }
Button {} label: { Image(systemName: "list.bullet") }
Button {} label: { Image(systemName: "moon.zzz") }
}
.padding(.horizontal, 16)
.padding(.bottom, 24)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white.opacity(0.75))
.padding(.top, 24)
Spacer(minLength: 26)
}
}
}
@@ -545,34 +317,6 @@ struct FullPlayerView: View {
let s = Int(seconds)
return "\(s / 60):\(String(format: "%02d", s % 60))"
}
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 var yearText: String {
// TODO: Could fetch actual publication year from book metadata
// For now, return current year or placeholder
let formatter = DateFormatter()
formatter.dateFormat = "yyyy"
return formatter.string(from: Date())
}
}
// MARK: - Custom seek slider