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

- 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:
Admin
2026-03-09 23:53:03 +05:00
parent 1766011b47
commit 0dcfdff65b

View File

@@ -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 {