ios: advance playback progress from server syncs
All checks were successful
TestFlight / testflight (push) Successful in 1m31s

This commit is contained in:
2026-07-19 01:12:32 -07:00
parent 71142e99fc
commit 366aa3b723
3 changed files with 55 additions and 39 deletions

View File

@@ -17,7 +17,6 @@ struct ContentView: View
MainView(model: $model)
.task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await pollPlaybackProgress() }
.task { await watchForSettingsChanges() }
.onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase)
@@ -74,11 +73,13 @@ extension ContentView
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
model.nowPlayingViewModel.timePosition = nowPlaying.timePosition
model.nowPlayingViewModel.duration = nowPlaying.duration
model.nowPlayingViewModel.isSeekable = nowPlaying.seekable ?? false
model.nowPlayingViewModel.syncPlaybackPosition(
nowPlaying.timePosition,
duration: nowPlaying.duration,
isPlaying: !nowPlaying.isPaused,
isSeekable: nowPlaying.seekable ?? false
)
model.playlistModel.isPlaying = !nowPlaying.isPaused
model.favoritesModel.isPlaying = !nowPlaying.isPaused
}
@@ -113,17 +114,6 @@ extension ContentView
}
}
private func pollPlaybackProgress() async {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1.0))
guard !Task.isCancelled else { return }
if scenePhase == .active && model.nowPlayingViewModel.isPlaying {
await refresh(.nowPlaying)
}
}
}
private func watchWebsocket() async {
guard let api = model.selectedServer?.api else { return }

View File

@@ -61,12 +61,14 @@ struct NowPlayingMiniView: View {
}
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
if let progress = model.playbackProgress {
ProgressView(value: progress)
if model.playbackDuration != nil {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
ProgressView(value: model.playbackProgress(at: context.date) ?? 0.0)
.progressViewStyle(.linear)
.tint(.accentColor)
}
}
}
.background(
containerShape
.fill(tapGestureState ? .ultraThinMaterial : .bar)

View File

@@ -33,6 +33,7 @@ class NowPlayingViewModel
fileprivate var isSettingPlaybackPosition: Bool = false
fileprivate var settingPlaybackPosition: Double = 0.0
private var playbackPositionSyncedAt = Date.now
var playbackDuration: Double? {
guard let duration, duration.isFinite, duration > 0 else { return nil }
@@ -40,14 +41,35 @@ class NowPlayingViewModel
}
var displayedPlaybackPosition: Double {
let position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
displayedPlaybackPosition(at: .now)
}
func displayedPlaybackPosition(at date: Date) -> Double {
var position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
if isPlaying && !isSettingPlaybackPosition {
position += max(date.timeIntervalSince(playbackPositionSyncedAt), 0.0)
}
guard position.isFinite else { return 0.0 }
return min(max(position, 0.0), playbackDuration ?? position)
}
var playbackProgress: Double? {
func playbackProgress(at date: Date) -> Double? {
guard let playbackDuration else { return nil }
return displayedPlaybackPosition / playbackDuration
return displayedPlaybackPosition(at: date) / playbackDuration
}
func syncPlaybackPosition(_ position: Double?, duration: Double?, isPlaying: Bool, isSeekable: Bool) {
timePosition = position
self.duration = duration
self.isPlaying = isPlaying
self.isSeekable = isSeekable
playbackPositionSyncedAt = .now
}
fileprivate func seekOptimistically(to position: Double) {
timePosition = position
playbackPositionSyncedAt = .now
}
}
@@ -87,22 +109,24 @@ struct NowPlayingView: View
Spacer(minLength: 20.0)
if let duration = model.playbackDuration {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
VStack(spacing: 2.0) {
Slider(
value: playbackPositionBinding,
value: playbackPositionBinding(at: context.date),
in: 0.0...duration,
onEditingChanged: playbackPositionEditingChanged
)
.disabled(!model.isSeekable || nothingQueued)
HStack {
Text(formatTime(model.displayedPlaybackPosition))
Text(formatTime(model.displayedPlaybackPosition(at: context.date)))
Spacer()
Text(formatTime(duration))
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 4.0)
Spacer(minLength: 20.0)
@@ -170,9 +194,9 @@ struct NowPlayingView: View
}
}
private var playbackPositionBinding: Binding<Double> {
private func playbackPositionBinding(at date: Date) -> Binding<Double> {
Binding(
get: { model.displayedPlaybackPosition },
get: { model.displayedPlaybackPosition(at: date) },
set: { model.settingPlaybackPosition = $0 }
)
}
@@ -183,7 +207,7 @@ struct NowPlayingView: View
model.isSettingPlaybackPosition = true
} else if model.isSettingPlaybackPosition {
let position = model.settingPlaybackPosition
model.timePosition = position
model.seekOptimistically(to: position)
model.isSettingPlaybackPosition = false
model.onSeek(model, position)
}