2 Commits
Author SHA1 Message Date
buzzert 366aa3b723 ios: advance playback progress from server syncs
TestFlight / testflight (push) Successful in 1m31s
2026-07-19 01:12:32 -07:00
buzzert 71142e99fc ios: fix media input sizing and playback progress
TestFlight / testflight (push) Successful in 1m24s
2026-07-19 00:22:04 -07:00
6 changed files with 71 additions and 41 deletions
+2 -1
View File
@@ -50,8 +50,9 @@ struct RequestBuilder
} }
public func build() -> URLRequest { public func build() -> URLRequest {
var request = URLRequest(url: self.url) var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
request.httpMethod = self.httpMethod.rawValue request.httpMethod = self.httpMethod.rawValue
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
if let body { if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body request.httpBody = body
+3
View File
@@ -20,12 +20,14 @@ struct AddMediaView: View
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents) AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocapitalization(.none) .autocapitalization(.none)
.autocorrectionDisabled() .autocorrectionDisabled()
.frame(minWidth: 0.0, maxWidth: .infinity)
PasteButton(payloadType: String.self) { payload in PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return } guard let contents = payload.first else { return }
model.fieldContents = contents model.fieldContents = contents
} }
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.fixedSize()
} }
} }
@@ -98,6 +100,7 @@ struct SearchMediaView: View
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch) AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused) .focused($searchFieldFocused)
.frame(minWidth: 0.0, maxWidth: .infinity)
if !searchText.isEmpty { if !searchText.isEmpty {
Button { Button {
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
tf.delegate = context.coordinator tf.delegate = context.coordinator
tf.returnKeyType = .done tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical) tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
tf.autocorrectionType = .no tf.autocorrectionType = .no
tf.autocapitalizationType = .none tf.autocapitalizationType = .none
return tf return tf
@@ -64,6 +66,12 @@ struct AutofocusingTextField: UIViewRepresentable
} }
final class FirstResponderTextField: UITextField { final class FirstResponderTextField: UITextField {
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width = UIView.noIntrinsicMetric
return size
}
override func didMoveToSuperview() { override func didMoveToSuperview() {
super.didMoveToSuperview() super.didMoveToSuperview()
becomeFirstResponder() becomeFirstResponder()
+6 -16
View File
@@ -17,7 +17,6 @@ struct ContentView: View
MainView(model: $model) MainView(model: $model)
.task(id: websocketRestartTrigger) { await watchWebsocket() } .task(id: websocketRestartTrigger) { await watchWebsocket() }
.task { await refresh([.nowPlaying, .playlist, .favorites]) } .task { await refresh([.nowPlaying, .playlist, .favorites]) }
.task { await pollPlaybackProgress() }
.task { await watchForSettingsChanges() } .task { await watchForSettingsChanges() }
.onChange(of: scenePhase) { oldPhase, newPhase in .onChange(of: scenePhase) { oldPhase, newPhase in
handleScenePhaseChange(from: oldPhase, to: newPhase) handleScenePhaseChange(from: oldPhase, to: newPhase)
@@ -74,11 +73,13 @@ extension ContentView
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0 model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
model.nowPlayingViewModel.timePosition = nowPlaying.timePosition model.nowPlayingViewModel.syncPlaybackPosition(
model.nowPlayingViewModel.duration = nowPlaying.duration nowPlaying.timePosition,
model.nowPlayingViewModel.isSeekable = nowPlaying.seekable ?? false duration: nowPlaying.duration,
isPlaying: !nowPlaying.isPaused,
isSeekable: nowPlaying.seekable ?? false
)
model.playlistModel.isPlaying = !nowPlaying.isPaused model.playlistModel.isPlaying = !nowPlaying.isPaused
model.favoritesModel.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 { private func watchWebsocket() async {
guard let api = model.selectedServer?.api else { return } guard let api = model.selectedServer?.api else { return }
+9 -5
View File
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
var body: some View { var body: some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill" let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
let tapGesture = DragGesture(minimumDistance: 0) let tapGesture = DragGesture(minimumDistance: 0)
.updating($tapGestureState) { _, state, _ in .updating($tapGestureState) { _, state, _ in
state = true state = true
@@ -60,17 +61,20 @@ struct NowPlayingMiniView: View {
} }
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0)) .padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
if let progress = model.playbackProgress { if model.playbackDuration != nil {
ProgressView(value: progress) TimelineView(.periodic(from: .now, by: 1.0)) { context in
.progressViewStyle(.linear) ProgressView(value: model.playbackProgress(at: context.date) ?? 0.0)
.tint(.accentColor) .progressViewStyle(.linear)
.tint(.accentColor)
}
} }
} }
.background( .background(
RoundedRectangle(cornerRadius: 12) containerShape
.fill(tapGestureState ? .ultraThinMaterial : .bar) .fill(tapGestureState ? .ultraThinMaterial : .bar)
.stroke(.ultraThinMaterial, lineWidth: 1.0) .stroke(.ultraThinMaterial, lineWidth: 1.0)
) )
.clipShape(containerShape)
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0) .shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
.gesture(tapGesture) .gesture(tapGesture)
} }
+43 -19
View File
@@ -33,6 +33,7 @@ class NowPlayingViewModel
fileprivate var isSettingPlaybackPosition: Bool = false fileprivate var isSettingPlaybackPosition: Bool = false
fileprivate var settingPlaybackPosition: Double = 0.0 fileprivate var settingPlaybackPosition: Double = 0.0
private var playbackPositionSyncedAt = Date.now
var playbackDuration: Double? { var playbackDuration: Double? {
guard let duration, duration.isFinite, duration > 0 else { return nil } guard let duration, duration.isFinite, duration > 0 else { return nil }
@@ -40,14 +41,35 @@ class NowPlayingViewModel
} }
var displayedPlaybackPosition: Double { 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 } guard position.isFinite else { return 0.0 }
return min(max(position, 0.0), playbackDuration ?? position) return min(max(position, 0.0), playbackDuration ?? position)
} }
var playbackProgress: Double? { func playbackProgress(at date: Date) -> Double? {
guard let playbackDuration else { return nil } 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) Spacer(minLength: 20.0)
if let duration = model.playbackDuration { if let duration = model.playbackDuration {
VStack(spacing: 2.0) { TimelineView(.periodic(from: .now, by: 1.0)) { context in
Slider( VStack(spacing: 2.0) {
value: playbackPositionBinding, Slider(
in: 0.0...duration, value: playbackPositionBinding(at: context.date),
onEditingChanged: playbackPositionEditingChanged in: 0.0...duration,
) onEditingChanged: playbackPositionEditingChanged
.disabled(!model.isSeekable || nothingQueued) )
.disabled(!model.isSeekable || nothingQueued)
HStack { HStack {
Text(formatTime(model.displayedPlaybackPosition)) Text(formatTime(model.displayedPlaybackPosition(at: context.date)))
Spacer() Spacer()
Text(formatTime(duration)) Text(formatTime(duration))
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
} }
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
} }
.padding(.horizontal, 4.0) .padding(.horizontal, 4.0)
@@ -170,9 +194,9 @@ struct NowPlayingView: View
} }
} }
private var playbackPositionBinding: Binding<Double> { private func playbackPositionBinding(at date: Date) -> Binding<Double> {
Binding( Binding(
get: { model.displayedPlaybackPosition }, get: { model.displayedPlaybackPosition(at: date) },
set: { model.settingPlaybackPosition = $0 } set: { model.settingPlaybackPosition = $0 }
) )
} }
@@ -183,7 +207,7 @@ struct NowPlayingView: View
model.isSettingPlaybackPosition = true model.isSettingPlaybackPosition = true
} else if model.isSettingPlaybackPosition { } else if model.isSettingPlaybackPosition {
let position = model.settingPlaybackPosition let position = model.settingPlaybackPosition
model.timePosition = position model.seekOptimistically(to: position)
model.isSettingPlaybackPosition = false model.isSettingPlaybackPosition = false
model.onSeek(model, position) model.onSeek(model, position)
} }