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). /// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {} var onDismiss: () -> Void = {}
@State private var showingSpeedMenu = false
@State private var showingChaptersList = false @State private var showingChaptersList = false
@State private var showingSleepTimer = false @State private var showingSleepTimer = false
var body: some View { var body: some View {
ZStack { GeometryReader { geo in
// Background: blurred cover art ZStack {
GeometryReader { geo in // Background: blurred cover art
KFImage(URL(string: audioPlayer.coverURL)) KFImage(URL(string: audioPlayer.coverURL))
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: geo.size.width, height: geo.size.height) .frame(width: geo.size.width, height: geo.size.height)
.clipped() .clipped()
.blur(radius: 40, opaque: true) .blur(radius: 50, opaque: true)
.overlay(Color.black.opacity(0.55)) .overlay(Color.black.opacity(0.5))
.ignoresSafeArea() .ignoresSafeArea()
}
.ignoresSafeArea()
// Content // Content
VStack(spacing: 0) { VStack(spacing: 0) {
// Drag handle pill visual cue that you can swipe down to close // Drag handle
Capsule() Capsule()
.fill(Color.white.opacity(0.35)) .fill(Color.white.opacity(0.3))
.frame(width: 36, height: 4) .frame(width: 36, height: 4)
.padding(.top, 12) .padding(.top, 14)
.padding(.bottom, 16)
// Cover art with watermark // Cover art
ZStack(alignment: .bottomLeading) { // Scales to fill ~55 % of screen height minus chrome
KFImage(URL(string: audioPlayer.coverURL)) let coverSize = min(geo.size.width - 56, geo.size.height * 0.42)
.resizable() ZStack {
.placeholder { KFImage(URL(string: audioPlayer.coverURL))
RoundedRectangle(cornerRadius: 18) .resizable()
.fill(.white.opacity(0.1)) .placeholder {
.overlay( RoundedRectangle(cornerRadius: 20)
Image(systemName: "book.closed") .fill(.white.opacity(0.08))
.font(.system(size: 48)) .overlay(
.foregroundStyle(.white.opacity(0.4)) 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 // Voice watermark bottom-left corner of cover
VStack(spacing: 4) { VStack {
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate()) Spacer()
.font(.title3.weight(.bold)) HStack {
.foregroundStyle(.white) Text(voiceName)
.multilineTextAlignment(.center) .font(.custom("Snell Roundhand", size: 18))
.lineLimit(2) .foregroundStyle(.white.opacity(0.55))
Text(audioPlayer.bookTitle) .shadow(color: .black.opacity(0.5), radius: 2)
.font(.subheadline) .padding(12)
.foregroundStyle(.white.opacity(0.65)) Spacer()
.lineLimit(1) }
// Chapter position indicator }
if !audioPlayer.chapters.isEmpty { .frame(width: coverSize, height: coverSize)
Text(chapterPositionText)
.font(.caption2.monospacedDigit())
.foregroundStyle(.white.opacity(0.4))
} }
} .frame(width: coverSize, height: coverSize)
.padding(.horizontal, 32) .padding(.top, 20)
.padding(.top, 20)
// Action buttons row + metadata inline // Title block
HStack(spacing: 0) { HStack(alignment: .center, spacing: 12) {
Spacer() VStack(alignment: .leading, spacing: 3) {
Text((audioPlayer.chapterTitle.isEmpty
// Metadata pill (only when ready) ? "Chapter \(audioPlayer.chapter)"
if audioPlayer.status != .generating { : audioPlayer.chapterTitle).strippingTrailingDate())
Text("\(yearText) · \(cacheStatusText) · OPUS") .font(.title3.weight(.bold))
.font(.caption2) .foregroundStyle(.white)
.foregroundStyle(.white.opacity(0.35)) .lineLimit(2)
.padding(.horizontal, 8) Text(audioPlayer.bookTitle)
} .font(.subheadline)
.foregroundStyle(.white.opacity(0.6))
Menu { .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 { Button {
audioPlayer.autoNext.toggle() audioPlayer.autoNext.toggle()
} label: { } label: {
Label( Image(systemName: audioPlayer.autoNext ? "infinity.circle.fill" : "infinity.circle")
audioPlayer.autoNext ? "Disable Auto-next" : "Enable Auto-next", .font(.system(size: 28))
systemImage: audioPlayer.autoNext ? "checkmark" : "" .foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.45))
) .contentTransition(.symbolEffect(.replace))
} }
} label: { .buttonStyle(.plain)
Image(systemName: "ellipsis.circle")
.font(.system(size: 22))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 40, height: 40)
} }
.buttonStyle(.plain) .padding(.horizontal, 28)
.padding(.top, 22)
Spacer()
}
.padding(.top, 10)
// Seek bar hidden while generating // Seek bar
if audioPlayer.status != .generating {
PlayerProgressSection( PlayerProgressSection(
progress: audioPlayer.progress, progress: audioPlayer.progress,
onSeek: { audioPlayer.seek(to: $0) } onSeek: { audioPlayer.seek(to: $0) }
) )
} else { .padding(.top, 18)
// Generating state: compact progress indicator with label .opacity(audioPlayer.status == .generating ? 0.3 : 1)
VStack(spacing: 8) { .allowsHitTesting(audioPlayer.status != .generating)
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 // Transport controls
HStack(spacing: 0) { // Layout: [skip-15] [prev-ch] [PLAY/PAUSE] [next-ch] [skip+15]
// skip back 15s // Outer skip buttons are smaller; prev/next are medium; center is large circle
Button { audioPlayer.skip(by: -15) } label: { HStack(spacing: 0) {
Image(systemName: "gobackward.15") // skip 15 s
.font(.system(size: 22, weight: .regular)) PlayerSecondaryButton(
.foregroundStyle(.white.opacity(audioPlayer.status == .generating ? 0.3 : 0.9)) systemName: "gobackward.15",
.frame(maxWidth: .infinity) size: 24,
} disabled: audioPlayer.status == .generating
.buttonStyle(.plain) ) { audioPlayer.skip(by: -15) }
.disabled(audioPlayer.status == .generating)
// previous chapter
// previous chapter PlayerChapterSkipButton(
Button { systemName: "backward.end.fill",
if let prev = audioPlayer.absolutePrevChapter { size: 30,
onDismiss() disabled: audioPlayer.absolutePrevChapter == nil
NotificationCenter.default.post( ) {
name: .skipToPrevChapter, if let prev = audioPlayer.absolutePrevChapter {
object: nil, onDismiss()
userInfo: ["prev": prev] 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 // Play / pause large circle
PlayerPlayPauseButton( PlayerPlayPauseButton(
progress: audioPlayer.progress, progress: audioPlayer.progress,
isGenerating: audioPlayer.status == .generating, isGenerating: audioPlayer.status == .generating,
onToggle: { audioPlayer.togglePlayPause() } onToggle: { audioPlayer.togglePlayPause() }
) )
// next chapter // next chapter
Button { PlayerChapterSkipButton(
if let next = audioPlayer.absoluteNextChapter { systemName: "forward.end.fill",
onDismiss() size: 30,
NotificationCenter.default.post( disabled: audioPlayer.absoluteNextChapter == nil,
name: .skipToNextChapter, prefetching: audioPlayer.nextPrefetchStatus == .prefetching
object: nil, ) {
userInfo: ["next": next] if let next = audioPlayer.absoluteNextChapter {
) onDismiss()
NotificationCenter.default.post(
name: .skipToNextChapter, object: nil,
userInfo: ["next": next]
)
}
} }
} label: {
ZStack { // skip 15 s
Image(systemName: "forward.end.fill") PlayerSecondaryButton(
.font(.system(size: 28, weight: .regular)) systemName: "goforward.15",
.foregroundStyle(.white.opacity(0.9)) size: 24,
disabled: audioPlayer.status == .generating
if audioPlayer.nextPrefetchStatus == .prefetching { ) { audioPlayer.skip(by: 15) }
VStack { }
Spacer() .padding(.horizontal, 16)
HStack { .padding(.top, 16)
Spacer() .padding(.bottom, 8)
ProgressView()
.scaleEffect(0.55) // Bottom toolbar
.tint(.amber) // AirPlay | Speed | Chevron-down | List | Moon
.padding(3) HStack(spacing: 0) {
.background(Circle().fill(.black.opacity(0.6))) // 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)
}
.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 // Collapse
HStack(spacing: 0) { Button { onDismiss() } label: {
// AirPlay Image(systemName: "chevron.down")
AirPlayButton() .font(.system(size: 20, weight: .semibold))
.frame(width: 22, height: 22) .foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 44)
// Speed control }
Menu { .buttonStyle(.plain)
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in
Button { // Chapters list
audioPlayer.setSpeed(s) Button { showingChaptersList = true } label: {
} label: { Image(systemName: "list.bullet")
if s == audioPlayer.speed { .font(.system(size: 20))
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark") .foregroundStyle(.white.opacity(0.7))
} else { .frame(maxWidth: .infinity)
Text("\(s, specifier: "%.2g")×") .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: { .buttonStyle(.plain)
// 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) .padding(.horizontal, 12)
.padding(.bottom, 8)
// 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, 16) .ignoresSafeArea(edges: .bottom)
.padding(.bottom, 12)
} }
.ignoresSafeArea(edges: .bottom)
} }
.ignoresSafeArea()
.sheet(isPresented: $showingChaptersList) { .sheet(isPresented: $showingChaptersList) {
ChaptersListSheet( ChaptersListSheet(
chapters: audioPlayer.chapters, chapters: audioPlayer.chapters,
@@ -501,7 +471,6 @@ struct FullPlayerView: View {
guard selectedChapter != audioPlayer.chapter else { return } guard selectedChapter != audioPlayer.chapter else { return }
let currentAudioChapter = audioPlayer.chapter let currentAudioChapter = audioPlayer.chapter
// Find the chapter metadata from the loaded list
let chapterTitle = audioPlayer.chapters let chapterTitle = audioPlayer.chapters
.first(where: { $0.number == selectedChapter })?.title ?? "" .first(where: { $0.number == selectedChapter })?.title ?? ""
let nextChapter = audioPlayer.chapters let nextChapter = audioPlayer.chapters
@@ -509,7 +478,6 @@ struct FullPlayerView: View {
.min(by: { $0.number < $1.number })?.number .min(by: { $0.number < $1.number })?.number
let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil let prevChapter: Int? = selectedChapter > 1 ? selectedChapter - 1 : nil
// Load & start playing the selected chapter directly
audioPlayer.load( audioPlayer.load(
slug: audioPlayer.slug, slug: audioPlayer.slug,
chapter: selectedChapter, chapter: selectedChapter,
@@ -523,14 +491,11 @@ struct FullPlayerView: View {
prevChapter: prevChapter prevChapter: prevChapter
) )
// Also navigate the text reader if it's open
let notifName: Notification.Name = selectedChapter > currentAudioChapter let notifName: Notification.Name = selectedChapter > currentAudioChapter
? .skipToNextChapter ? .skipToNextChapter : .skipToPrevChapter
: .skipToPrevChapter
let key = selectedChapter > currentAudioChapter ? "next" : "prev" let key = selectedChapter > currentAudioChapter ? "next" : "prev"
NotificationCenter.default.post( NotificationCenter.default.post(
name: notifName, name: notifName, object: nil,
object: nil,
userInfo: [key: selectedChapter] userInfo: [key: selectedChapter]
) )
} }
@@ -545,52 +510,89 @@ struct FullPlayerView: View {
} }
} }
// MARK: - Helpers
private var chapterPositionText: String { private var chapterPositionText: String {
let total = audioPlayer.chapters.count let total = audioPlayer.chapters.count
guard total > 0 else { return "" } 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 sorted = audioPlayer.chapters.sorted(by: { $0.number < $1.number })
let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1 let idx = (sorted.firstIndex(where: { $0.number == audioPlayer.chapter }) ?? 0) + 1
return "Chapter \(idx) of \(total)" return "Chapter \(idx) of \(total)"
} }
private var voiceName: String { private var voiceName: String {
// Extract voice name from audioPlayer.voice (e.g., "af_bella" -> "Bella")
let components = audioPlayer.voice.split(separator: "_") let components = audioPlayer.voice.split(separator: "_")
if components.count > 1 { if components.count > 1 { return String(components[1]).capitalized }
return String(components[1]).capitalized
}
return audioPlayer.voice.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 { private var sleepTimerIcon: String {
audioPlayer.sleepTimer != nil ? "moon.zzz.fill" : "moon.zzz" 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 // MARK: - AirPlay Button
struct AirPlayButton: UIViewControllerRepresentable { struct AirPlayButton: UIViewControllerRepresentable {