iOS: fix player menu blink and stale checkmarks by isolating high-frequency playback state
Some checks failed
CI / Scraper / Test (pull_request) Failing after 8s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 12s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

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).
This commit is contained in:
Admin
2026-03-08 15:42:39 +05:00
parent 2793ad8cfa
commit 5ba84f7945
2 changed files with 189 additions and 86 deletions

View File

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

View File

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