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) + } +}