diff --git a/ios/QueueCube/Views/ContentView.swift b/ios/QueueCube/Views/ContentView.swift index b9ae88c..6f769bd 100644 --- a/ios/QueueCube/Views/ContentView.swift +++ b/ios/QueueCube/Views/ContentView.swift @@ -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 } diff --git a/ios/QueueCube/Views/NowPlayingMiniView.swift b/ios/QueueCube/Views/NowPlayingMiniView.swift index a29fb3e..9db01a7 100644 --- a/ios/QueueCube/Views/NowPlayingMiniView.swift +++ b/ios/QueueCube/Views/NowPlayingMiniView.swift @@ -61,10 +61,12 @@ 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) - .progressViewStyle(.linear) - .tint(.accentColor) + 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( diff --git a/ios/QueueCube/Views/NowPlayingView.swift b/ios/QueueCube/Views/NowPlayingView.swift index 3eba125..4e57853 100644 --- a/ios/QueueCube/Views/NowPlayingView.swift +++ b/ios/QueueCube/Views/NowPlayingView.swift @@ -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,21 +109,23 @@ struct NowPlayingView: View Spacer(minLength: 20.0) if let duration = model.playbackDuration { - VStack(spacing: 2.0) { - Slider( - value: playbackPositionBinding, - in: 0.0...duration, - onEditingChanged: playbackPositionEditingChanged - ) - .disabled(!model.isSeekable || nothingQueued) + TimelineView(.periodic(from: .now, by: 1.0)) { context in + VStack(spacing: 2.0) { + Slider( + value: playbackPositionBinding(at: context.date), + in: 0.0...duration, + onEditingChanged: playbackPositionEditingChanged + ) + .disabled(!model.isSeekable || nothingQueued) - HStack { - Text(formatTime(model.displayedPlaybackPosition)) - Spacer() - Text(formatTime(duration)) + HStack { + Text(formatTime(model.displayedPlaybackPosition(at: context.date))) + Spacer() + Text(formatTime(duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) } - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) } .padding(.horizontal, 4.0) @@ -170,9 +194,9 @@ struct NowPlayingView: View } } - private var playbackPositionBinding: Binding { + private func playbackPositionBinding(at date: Date) -> Binding { 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) }