feat(ios): Apple Books full player layout redesign
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m45s
iOS CI / Build (pull_request) Successful in 3m56s
iOS CI / Test (push) Failing after 3m41s
iOS CI / Test (pull_request) Failing after 10m29s
Some checks failed
CI / Scraper / Test (pull_request) Successful in 8s
CI / Scraper / Lint (pull_request) Successful in 14s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Build (pull_request) Successful in 21s
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (push) Successful in 1m45s
iOS CI / Build (pull_request) Successful in 3m56s
iOS CI / Test (push) Failing after 3m41s
iOS CI / Test (pull_request) Failing after 10m29s
- Outer GeometryReader for adaptive cover sizing (min of width-56 / height*0.42) - Generating state: cover dims with 0.45 overlay + spinner; seek bar stays visible at opacity 0.3 - Title block: left-aligned with auto-next infinity.circle toggle on far right - Removed ellipsis menu, year/cache metadata row, showingSpeedMenu state var - New PlayerSecondaryButton and PlayerChapterSkipButton private helper structs - Bottom toolbar: AirPlay | Speed | chevron.down | list.bullet | moon (44pt touch targets) - ignoresSafeArea moved to outermost GeometryReader level
This commit is contained in:
@@ -204,294 +204,264 @@ struct FullPlayerView: View {
|
||||
/// 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
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
// ── Background: blurred cover art ──────────────────────────
|
||||
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))
|
||||
.blur(radius: 50, opaque: true)
|
||||
.overlay(Color.black.opacity(0.5))
|
||||
.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)
|
||||
// ── Content ────────────────────────────────────────────────
|
||||
VStack(spacing: 0) {
|
||||
// Drag handle
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
.frame(width: 36, height: 4)
|
||||
.padding(.top, 14)
|
||||
|
||||
// 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))
|
||||
)
|
||||
// ── Cover art ──────────────────────────────────────────
|
||||
// Scales to fill ~55 % of screen height minus chrome
|
||||
let coverSize = min(geo.size.width - 56, geo.size.height * 0.42)
|
||||
ZStack {
|
||||
KFImage(URL(string: audioPlayer.coverURL))
|
||||
.resizable()
|
||||
.placeholder {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(.white.opacity(0.08))
|
||||
.overlay(
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.white.opacity(0.3))
|
||||
)
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: coverSize, height: coverSize)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.shadow(color: .black.opacity(0.6), radius: 32, y: 16)
|
||||
// Dim cover while generating
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(audioPlayer.status == .generating ? 0.45 : 0))
|
||||
)
|
||||
|
||||
// Generating spinner centred over cover
|
||||
if audioPlayer.status == .generating {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.4)
|
||||
Text("Generating audio…")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
}
|
||||
.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)
|
||||
// Chapter position indicator
|
||||
if !audioPlayer.chapters.isEmpty {
|
||||
Text(chapterPositionText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
// Voice watermark — bottom-left corner of cover
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Text(voiceName)
|
||||
.font(.custom("Snell Roundhand", size: 18))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.shadow(color: .black.opacity(0.5), radius: 2)
|
||||
.padding(12)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(width: coverSize, height: coverSize)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 20)
|
||||
.frame(width: coverSize, height: coverSize)
|
||||
.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 {
|
||||
// ── Title block ────────────────────────────────────────
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text((audioPlayer.chapterTitle.isEmpty
|
||||
? "Chapter \(audioPlayer.chapter)"
|
||||
: audioPlayer.chapterTitle).strippingTrailingDate())
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
Text(audioPlayer.bookTitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
if !audioPlayer.chapters.isEmpty {
|
||||
Text(chapterPositionText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Auto-next toggle (heart-like button on right of title)
|
||||
Button {
|
||||
audioPlayer.autoNext.toggle()
|
||||
} label: {
|
||||
Label(
|
||||
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next",
|
||||
systemImage: audioPlayer.autoNext ? "checkmark" : ""
|
||||
)
|
||||
Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.45))
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 40, height: 40)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 22)
|
||||
|
||||
// Seek bar — hidden while generating
|
||||
if audioPlayer.status != .generating {
|
||||
// ── Seek bar ───────────────────────────────────────────
|
||||
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)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.opacity(audioPlayer.status == .generating ? 0.3 : 1)
|
||||
.allowsHitTesting(audioPlayer.status != .generating)
|
||||
|
||||
// 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]
|
||||
)
|
||||
// ── Transport controls ─────────────────────────────────
|
||||
// Layout: [skip-15] [prev-ch] [PLAY/PAUSE] [next-ch] [skip+15]
|
||||
// Outer skip buttons are smaller; prev/next are medium; center is large circle
|
||||
HStack(spacing: 0) {
|
||||
// ← skip 15 s
|
||||
PlayerSecondaryButton(
|
||||
systemName: "gobackward.15",
|
||||
size: 24,
|
||||
disabled: audioPlayer.status == .generating
|
||||
) { audioPlayer.skip(by: -15) }
|
||||
|
||||
// ← previous chapter
|
||||
PlayerChapterSkipButton(
|
||||
systemName: "backward.end.fill",
|
||||
size: 30,
|
||||
disabled: audioPlayer.absolutePrevChapter == nil
|
||||
) {
|
||||
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() }
|
||||
)
|
||||
// Play / pause — large circle
|
||||
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]
|
||||
)
|
||||
// → next chapter
|
||||
PlayerChapterSkipButton(
|
||||
systemName: "forward.end.fill",
|
||||
size: 30,
|
||||
disabled: audioPlayer.absoluteNextChapter == nil,
|
||||
prefetching: audioPlayer.nextPrefetchStatus == .prefetching
|
||||
) {
|
||||
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)))
|
||||
|
||||
// → skip 15 s
|
||||
PlayerSecondaryButton(
|
||||
systemName: "goforward.15",
|
||||
size: 24,
|
||||
disabled: audioPlayer.status == .generating
|
||||
) { audioPlayer.skip(by: 15) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// ── Bottom toolbar ─────────────────────────────────────
|
||||
// AirPlay | Speed | Chevron-down | List | Moon
|
||||
HStack(spacing: 0) {
|
||||
// AirPlay
|
||||
AirPlayButton()
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
// Speed
|
||||
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: {
|
||||
Text("\(audioPlayer.speed, specifier: "%.2g")×")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.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)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 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")×")
|
||||
// Collapse
|
||||
Button { onDismiss() } label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Chapters list
|
||||
Button { showingChaptersList = true } label: {
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Sleep timer
|
||||
Button { showingSleepTimer = true } label: {
|
||||
VStack(spacing: 1) {
|
||||
Image(systemName: sleepTimerIcon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(audioPlayer.sleepTimer != nil ? Color.amber : .white.opacity(0.7))
|
||||
if !audioPlayer.sleepTimerRemainingText.isEmpty {
|
||||
Text(audioPlayer.sleepTimerRemainingText)
|
||||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
.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: {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: sleepTimerIcon)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(audioPlayer.sleepTimer != nil ? .amber : .white.opacity(0.7))
|
||||
if !audioPlayer.sleepTimerRemainingText.isEmpty {
|
||||
Text(audioPlayer.sleepTimerRemainingText)
|
||||
.font(.system(size: 9, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(Color.amber)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 12)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.sheet(isPresented: $showingChaptersList) {
|
||||
ChaptersListSheet(
|
||||
chapters: audioPlayer.chapters,
|
||||
@@ -501,7 +471,6 @@ struct FullPlayerView: View {
|
||||
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
|
||||
@@ -509,7 +478,6 @@ struct FullPlayerView: View {
|
||||
.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,
|
||||
@@ -523,14 +491,11 @@ struct FullPlayerView: View {
|
||||
prevChapter: prevChapter
|
||||
)
|
||||
|
||||
// Also navigate the text reader if it's open
|
||||
let notifName: Notification.Name = selectedChapter > currentAudioChapter
|
||||
? .skipToNextChapter
|
||||
: .skipToPrevChapter
|
||||
? .skipToNextChapter : .skipToPrevChapter
|
||||
let key = selectedChapter > currentAudioChapter ? "next" : "prev"
|
||||
NotificationCenter.default.post(
|
||||
name: notifName,
|
||||
object: nil,
|
||||
name: notifName, object: nil,
|
||||
userInfo: [key: selectedChapter]
|
||||
)
|
||||
}
|
||||
@@ -545,52 +510,89 @@ struct FullPlayerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var chapterPositionText: String {
|
||||
let total = audioPlayer.chapters.count
|
||||
guard total > 0 else { return "" }
|
||||
// Find the 1-based position (index) of the current chapter within the sorted list.
|
||||
let sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number })
|
||||
let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1
|
||||
return "Chapter \(idx) of \(total)"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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: - Small secondary transport button (±15 s skips)
|
||||
|
||||
private struct PlayerSecondaryButton: View {
|
||||
let systemName: String
|
||||
let size: CGFloat
|
||||
let disabled: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.85))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 64)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Medium chapter-skip button (prev / next chapter)
|
||||
|
||||
private struct PlayerChapterSkipButton: View {
|
||||
let systemName: String
|
||||
let size: CGFloat
|
||||
let disabled: Bool
|
||||
var prefetching: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: size, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(disabled ? 0.3 : 0.9))
|
||||
|
||||
if prefetching {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.55)
|
||||
.tint(.amber)
|
||||
.padding(3)
|
||||
.background(Circle().fill(.black.opacity(0.6)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 64)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
.opacity(disabled ? 0.4 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AirPlay Button
|
||||
|
||||
struct AirPlayButton: UIViewControllerRepresentable {
|
||||
|
||||
Reference in New Issue
Block a user