Compare commits
2 Commits
3d9d8ab365
...
37bd73651a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37bd73651a | ||
|
|
466e289b68 |
@@ -35,6 +35,6 @@ extension String {
|
||||
|
||||
enum AppLayout {
|
||||
/// Height of the persistent mini-player bar:
|
||||
/// 2pt progress line + 20pt vertical padding + ~44pt content row.
|
||||
static let miniPlayerBarHeight: CGFloat = 66
|
||||
/// 12pt vertical padding (top) + 56pt cover height + 12pt vertical padding (bottom) + 12pt horizontal margin.
|
||||
static let miniPlayerBarHeight: CGFloat = 92
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 = ""
|
||||
@@ -73,7 +74,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?) {
|
||||
chapters: [ChapterIndexBrief], nextChapter: Int?, prevChapter: Int?) {
|
||||
generationTask?.cancel()
|
||||
prefetchTask?.cancel()
|
||||
stop()
|
||||
@@ -87,6 +88,7 @@ final class AudioPlayerService: ObservableObject {
|
||||
self.speed = speed
|
||||
self.chapters = chapters
|
||||
self.nextChapter = nextChapter
|
||||
self.prevChapter = prevChapter
|
||||
self.nextPrefetchStatus = .none
|
||||
self.nextAudioURL = ""
|
||||
self.nextPrefetchedChapter = nil
|
||||
@@ -328,6 +330,7 @@ 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.
|
||||
@@ -338,6 +341,7 @@ final class AudioPlayerService: ObservableObject {
|
||||
chapter = next
|
||||
chapterTitle = nextTitle
|
||||
nextChapter = nextNextChapter
|
||||
prevChapter = nextPrevChapter
|
||||
nextPrefetchStatus = .none
|
||||
nextAudioURL = ""
|
||||
nextPrefetchedChapter = nil
|
||||
@@ -360,7 +364,8 @@ final class AudioPlayerService: ObservableObject {
|
||||
voice: voice,
|
||||
speed: speed,
|
||||
chapters: chapters,
|
||||
nextChapter: nextNextChapter
|
||||
nextChapter: nextNextChapter,
|
||||
prevChapter: nextPrevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -462,4 +467,6 @@ enum AudioPlayerStatus: Equatable {
|
||||
|
||||
extension Notification.Name {
|
||||
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
|
||||
static let skipToNextChapter = Notification.Name("skipToNextChapter")
|
||||
static let skipToPrevChapter = Notification.Name("skipToPrevChapter")
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
audioPlayer.togglePlayPause()
|
||||
} else {
|
||||
let nextChapter: Int? = content.next
|
||||
let prevChapter: Int? = content.prev
|
||||
audioPlayer.load(
|
||||
slug: slug,
|
||||
chapter: chapter,
|
||||
@@ -56,7 +57,8 @@ final class ChapterReaderViewModel: ObservableObject {
|
||||
voice: settings.voice,
|
||||
speed: settings.speed,
|
||||
chapters: content.chapters,
|
||||
nextChapter: nextChapter
|
||||
nextChapter: nextChapter,
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ struct BookDetailView: View {
|
||||
Button(summaryExpanded ? "Less" : "More") {
|
||||
withAnimation { summaryExpanded.toggle() }
|
||||
}
|
||||
.font(.caption.bold())
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.amber)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ 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 } }
|
||||
|
||||
@@ -11,101 +11,157 @@ struct MiniPlayerView: View {
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Progress line — thin amber strip at the very top
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2)
|
||||
Rectangle()
|
||||
.fill(Color.amber)
|
||||
.frame(width: geo.size.width * progress, height: 2)
|
||||
}
|
||||
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))
|
||||
}
|
||||
.frame(height: 2)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 14) {
|
||||
// Cover thumbnail
|
||||
Button { showFullPlayer = true } label: {
|
||||
AsyncCoverImage(url: audioPlayer.coverURL)
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.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 }
|
||||
|
||||
// Track info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(audioPlayer.bookTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(chapterLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.onTapGesture { showFullPlayer = true }
|
||||
Spacer(minLength: 8)
|
||||
|
||||
// Status indicator or playback control
|
||||
// Control buttons
|
||||
HStack(spacing: 16) {
|
||||
// Status indicator or play/pause control
|
||||
Group {
|
||||
switch audioPlayer.status {
|
||||
case .generating:
|
||||
ProgressView()
|
||||
.tint(.amber)
|
||||
.scaleEffect(0.85)
|
||||
.frame(width: 36, height: 36)
|
||||
.tint(.white)
|
||||
.scaleEffect(1.0)
|
||||
.frame(width: 44, height: 44)
|
||||
case .ready:
|
||||
Button { audioPlayer.togglePlayPause() } label: {
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 36, height: 36)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.red)
|
||||
.frame(width: 36, height: 36)
|
||||
.frame(width: 44, height: 44)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss
|
||||
Button { audioPlayer.stop() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30, height: 30)
|
||||
// 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)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.15), radius: 6, y: -2)
|
||||
// Follow finger upward while dragging, spring back or expand on release
|
||||
.offset(y: min(dragOffset, 0))
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.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)
|
||||
}
|
||||
)
|
||||
.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)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 10, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
// Only track upward swipes here; downward dismiss is on ended
|
||||
if value.translation.height < 0 {
|
||||
// Rubberband: feels lighter as you drag further
|
||||
// Upward swipe: rubberband resistance (opens full player)
|
||||
dragOffset = value.translation.height * 0.4
|
||||
} else {
|
||||
dragOffset = value.translation.height * 0.15
|
||||
// Downward swipe: less resistance for easier dismiss
|
||||
dragOffset = value.translation.height * 0.8
|
||||
}
|
||||
}
|
||||
.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 > 40 || velocity > 200 {
|
||||
audioPlayer.stop()
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -130,6 +186,10 @@ 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
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -157,23 +217,32 @@ struct FullPlayerView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Cover art
|
||||
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)
|
||||
.padding(.horizontal, 48)
|
||||
// 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)
|
||||
|
||||
@@ -191,6 +260,103 @@ struct FullPlayerView: View {
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.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))
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
|
||||
// Seek bar
|
||||
VStack(spacing: 6) {
|
||||
@@ -204,7 +370,7 @@ struct FullPlayerView: View {
|
||||
HStack {
|
||||
Text(formatTime(audioPlayer.currentTime))
|
||||
Spacer()
|
||||
Text(formatTime(audioPlayer.duration))
|
||||
Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime))
|
||||
}
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
@@ -217,38 +383,73 @@ struct FullPlayerView: View {
|
||||
// ← skip back 15s
|
||||
Button { audioPlayer.skip(by: -15) } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 26, weight: .regular))
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.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.amber)
|
||||
.frame(width: 72, height: 72)
|
||||
.shadow(color: Color.amber.opacity(0.45), radius: 14, y: 4)
|
||||
if audioPlayer.status == .generating {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.1)
|
||||
.scaleEffect(1.3)
|
||||
} else {
|
||||
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.font(.system(size: 38, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 2)
|
||||
.offset(x: audioPlayer.isPlaying ? 0 : 3)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(audioPlayer.status == .generating)
|
||||
|
||||
// → skip forward 30s
|
||||
Button { audioPlayer.skip(by: 30) } label: {
|
||||
Image(systemName: "goforward.30")
|
||||
.font(.system(size: 26, weight: .regular))
|
||||
// → 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")
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -257,8 +458,21 @@ struct FullPlayerView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 32)
|
||||
|
||||
// Speed + Auto-next
|
||||
HStack(spacing: 20) {
|
||||
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 {
|
||||
@@ -272,49 +486,56 @@ struct FullPlayerView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.white.opacity(0.18), in: Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Auto-next toggle
|
||||
Button {
|
||||
audioPlayer.autoNext.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "forward.end")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text("Auto-next")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.55))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
audioPlayer.autoNext
|
||||
? Color.amber.opacity(0.18)
|
||||
: Color.white.opacity(0.1),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(
|
||||
audioPlayer.autoNext ? Color.amber.opacity(0.5) : Color.clear,
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
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)
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 28)
|
||||
|
||||
Spacer(minLength: 32)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +545,34 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user