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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user