From 0dcfdff65b30c0527dfa57589b4ed1082cdd2efc Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 9 Mar 2026 23:53:03 +0500 Subject: [PATCH] feat(ios): Apple Books full player layout redesign - 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 --- .../LibNovel/Views/Player/PlayerViews.swift | 562 +++++++++--------- 1 file changed, 282 insertions(+), 280 deletions(-) diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index d8bdc39..e433efb 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -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 {