From 5ba84f79456e5a101cbff66050f519eeb5b90940 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 8 Mar 2026 15:42:39 +0500 Subject: [PATCH] iOS: fix player menu blink and stale checkmarks by isolating high-frequency playback state Extract currentTime/duration/isPlaying into a separate PlaybackProgress ObservableObject so the 0.5s time-observer ticks only invalidate the seek bar and play-pause button subviews, not the entire FullPlayerView or MiniPlayerView. Menus no longer blink or lose their checkmarks during playback. Also fix chapter list selection in FullPlayerView to call audioPlayer.load() directly instead of relying on skip notifications (which only worked when ChapterReaderView was open). --- .../Services/AudioPlayerService.swift | 34 ++- .../LibNovel/Views/Player/PlayerViews.swift | 241 ++++++++++++------ 2 files changed, 189 insertions(+), 86 deletions(-) diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index e1c92d0..6778575 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -3,6 +3,19 @@ import AVFoundation import MediaPlayer import Combine +// MARK: - PlaybackProgress +// Isolated ObservableObject for high-frequency playback state (currentTime, +// duration, isPlaying). Keeping these separate from AudioPlayerService means +// the 0.5-second time-observer ticks only invalidate views that explicitly +// observe PlaybackProgress — menus and other stable UI are unaffected. + +@MainActor +final class PlaybackProgress: ObservableObject { + @Published var currentTime: Double = 0 + @Published var duration: Double = 0 + @Published var isPlaying: Bool = false +} + // MARK: - AudioPlayerService // Central singleton that owns AVPlayer, drives audio state, handles lock-screen // controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the @@ -27,9 +40,24 @@ final class AudioPlayerService: ObservableObject { @Published var errorMessage: String = "" @Published var generationProgress: Double = 0 - @Published var currentTime: Double = 0 - @Published var duration: Double = 0 - @Published var isPlaying: Bool = false + /// High-frequency playback state (currentTime / duration / isPlaying). + /// Views that only need the seek bar or play-pause button should observe + /// this directly so they don't trigger re-renders of menu-bearing parents. + let progress = PlaybackProgress() + + // Convenience forwarders so non-view call sites keep compiling unchanged. + var currentTime: Double { + get { progress.currentTime } + set { progress.currentTime = newValue } + } + var duration: Double { + get { progress.duration } + set { progress.duration = newValue } + } + var isPlaying: Bool { + get { progress.isPlaying } + set { progress.isPlaying = newValue } + } @Published var autoNext: Bool = false @Published var nextChapter: Int? = nil diff --git a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift index eafc701..25312b3 100644 --- a/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift +++ b/ios/LibNovel/LibNovel/Views/Player/PlayerViews.swift @@ -14,18 +14,7 @@ struct MiniPlayerView: View { var body: some View { ZStack { // Static progress bar as background (full bleed behind content) - GeometryReader { geo in - ZStack(alignment: .leading) { - // Background track - full pill shape - RoundedRectangle(cornerRadius: 40) - .fill(Color.white.opacity(0.2)) - - // Progress fill - rounded to match pill shape - RoundedRectangle(cornerRadius: 40) - .fill(Color.amber.opacity(0.3)) - .frame(width: max(0, geo.size.width * progress)) - } - } + MiniPlayerProgressBar(progress: audioPlayer.progress) // Content layer HStack(spacing: 16) { @@ -86,14 +75,10 @@ struct MiniPlayerView: View { .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: 24, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + MiniPlayerPlayPauseButton( + progress: audioPlayer.progress, + onToggle: { audioPlayer.togglePlayPause() } + ) case .error: Image(systemName: "exclamationmark.circle.fill") .font(.system(size: 24)) @@ -205,11 +190,6 @@ struct MiniPlayerView: View { ) } - private var progress: CGFloat { - guard audioPlayer.duration > 0 else { return 0 } - return CGFloat(audioPlayer.currentTime / audioPlayer.duration) - } - private var chapterLabel: String { let raw = audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" @@ -330,24 +310,10 @@ struct FullPlayerView: View { // Seek bar — hidden while generating if audioPlayer.status != .generating { - VStack(spacing: 4) { - PlayerSlider( - value: Binding( - get: { audioPlayer.currentTime }, - set: { audioPlayer.seek(to: $0) } - ), - range: 0...max(audioPlayer.duration, 1) - ) - HStack { - Text(formatTime(audioPlayer.currentTime)) - Spacer() - Text("-" + formatTime(audioPlayer.duration - audioPlayer.currentTime)) - } - .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.5)) - } - .padding(.horizontal, 28) - .padding(.top, 16) + PlayerProgressSection( + progress: audioPlayer.progress, + onSeek: { audioPlayer.seek(to: $0) } + ) } else { // Generating state: compact progress indicator with label VStack(spacing: 8) { @@ -396,26 +362,11 @@ struct FullPlayerView: View { .opacity(audioPlayer.absolutePrevChapter == nil ? 0.4 : 1.0) // play / pause — large circle button - Button { audioPlayer.togglePlayPause() } label: { - ZStack { - Circle() - .fill(.white.opacity(0.15)) - .frame(width: 64, height: 64) - if audioPlayer.status == .generating { - ProgressView() - .tint(.white) - .scaleEffect(1.2) - } else { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 30, weight: .bold)) - .foregroundStyle(.white) - .offset(x: audioPlayer.isPlaying ? 0 : 2) - } - } - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .disabled(audioPlayer.status == .generating) + PlayerPlayPauseButton( + progress: audioPlayer.progress, + isGenerating: audioPlayer.status == .generating, + onToggle: { audioPlayer.togglePlayPause() } + ) // → next chapter Button { @@ -534,20 +485,41 @@ struct FullPlayerView: View { currentChapter: audioPlayer.chapter, onChapterSelect: { selectedChapter in showingChaptersList = false - // Post notification to navigate to selected chapter - if selectedChapter > audioPlayer.chapter { - NotificationCenter.default.post( - name: .skipToNextChapter, - object: nil, - userInfo: ["next": selectedChapter] - ) - } else if selectedChapter < audioPlayer.chapter { - NotificationCenter.default.post( - name: .skipToPrevChapter, - object: nil, - userInfo: ["prev": selectedChapter] - ) - } + 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 + .filter({ $0.number > selectedChapter }) + .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, + chapterTitle: chapterTitle, + bookTitle: audioPlayer.bookTitle, + coverURL: audioPlayer.coverURL, + voice: audioPlayer.voice, + speed: audioPlayer.speed, + chapters: audioPlayer.chapters, + nextChapter: nextChapter, + prevChapter: prevChapter + ) + + // Also navigate the text reader if it's open + let notifName: Notification.Name = selectedChapter > currentAudioChapter + ? .skipToNextChapter + : .skipToPrevChapter + let key = selectedChapter > currentAudioChapter ? "next" : "prev" + NotificationCenter.default.post( + name: notifName, + object: nil, + userInfo: [key: selectedChapter] + ) } ) .presentationDetents([.medium, .large]) @@ -560,12 +532,6 @@ struct FullPlayerView: View { } } - private func formatTime(_ seconds: Double) -> String { - guard seconds.isFinite, seconds >= 0 else { return "0:00" } - 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: "_") @@ -851,3 +817,112 @@ struct PlayerSlider: View { .frame(height: 28) } } + +// MARK: - Isolated mini-player progress bar background + +private struct MiniPlayerProgressBar: View { + @ObservedObject var progress: PlaybackProgress + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 40) + .fill(Color.white.opacity(0.2)) + RoundedRectangle(cornerRadius: 40) + .fill(Color.amber.opacity(0.3)) + .frame(width: max(0, geo.size.width * fraction)) + } + } + } + + private var fraction: CGFloat { + guard progress.duration > 0 else { return 0 } + return CGFloat(progress.currentTime / progress.duration) + } +} + +// MARK: - Isolated progress section (seek bar + timestamps) +// Observes PlaybackProgress directly so the 0.5-second time ticks only +// invalidate this small view — not the menus or controls around it. + +private struct PlayerProgressSection: View { + @ObservedObject var progress: PlaybackProgress + let onSeek: (Double) -> Void + + var body: some View { + VStack(spacing: 4) { + PlayerSlider( + value: Binding( + get: { progress.currentTime }, + set: { onSeek($0) } + ), + range: 0...max(progress.duration, 1) + ) + HStack { + Text(formatTime(progress.currentTime)) + Spacer() + Text("-" + formatTime(progress.duration - progress.currentTime)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.5)) + } + .padding(.horizontal, 28) + .padding(.top, 16) + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let s = Int(seconds) + return "\(s / 60):\(String(format: "%02d", s % 60))" + } +} + +// MARK: - Isolated play/pause button +// Observes PlaybackProgress so isPlaying changes only re-render this button. + +private struct PlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let isGenerating: Bool + let onToggle: () -> Void + + var body: some View { + Button { onToggle() } label: { + ZStack { + Circle() + .fill(.white.opacity(0.15)) + .frame(width: 64, height: 64) + if isGenerating { + ProgressView() + .tint(.white) + .scaleEffect(1.2) + } else { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(.white) + .offset(x: progress.isPlaying ? 0 : 2) + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + .disabled(isGenerating) + } +} + +// MARK: - Isolated mini-player play/pause button + +private struct MiniPlayerPlayPauseButton: View { + @ObservedObject var progress: PlaybackProgress + let onToggle: () -> Void + + var body: some View { + Button { onToggle() } label: { + Image(systemName: progress.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +}