2 Commits
Author SHA1 Message Date
buzzert 71142e99fc ios: fix media input sizing and playback progress
TestFlight / testflight (push) Successful in 1m24s
2026-07-19 00:22:04 -07:00
buzzert b47d60c17f ios: add playback progress and error reporting
TestFlight / testflight (push) Successful in 1m22s
2026-07-19 00:02:54 -07:00
8 changed files with 212 additions and 39 deletions
+20 -1
View File
@@ -62,6 +62,9 @@ struct NowPlayingInfo: Codable
let playingItem: MediaItem?
let isPaused: Bool
let volume: Int
let timePosition: Double?
let duration: Double?
let seekable: Bool?
}
actor API
@@ -172,6 +175,13 @@ actor API
.body([ "volume" : Int(value * 100) ])
.post()
}
public func seek(to time: Double) async throws {
try await request()
.path("/player/seek")
.body([ "time" : time ])
.post()
}
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
try await request()
@@ -255,10 +265,19 @@ actor API
// MARK: - Types
enum Error: Swift.Error
enum Error: Swift.Error, LocalizedError
{
case apiNotConfigured
case websocketError(Swift.Error)
var errorDescription: String? {
switch self {
case .apiNotConfigured:
"No server is configured."
case .websocketError(let error):
error.localizedDescription
}
}
}
enum StreamEvent {
+42 -9
View File
@@ -50,8 +50,9 @@ struct RequestBuilder
}
public func build() -> URLRequest {
var request = URLRequest(url: self.url)
var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
request.httpMethod = self.httpMethod.rawValue
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
@@ -61,8 +62,7 @@ struct RequestBuilder
}
public func json<T: Decodable>() async throws -> T {
let urlRequest = self.build()
let (data, _) = try await URLSession.shared.data(for: urlRequest)
let data = try await responseData()
return try JSONDecoder().decode(T.self, from: data)
}
@@ -71,13 +71,24 @@ struct RequestBuilder
}
public func execute() async throws {
let urlRequest = self.build()
let (data, response) = try await URLSession.shared.data(for: urlRequest)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode != 200 {
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)")
}
_ = try await responseData()
}
private func responseData() async throws -> Data {
let (data, response) = try await URLSession.shared.data(for: build())
guard let httpResponse = response as? HTTPURLResponse else {
throw RequestError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
let serverError = try? JSONDecoder().decode(ServerErrorResponse.self, from: data)
throw RequestError.httpError(
statusCode: httpResponse.statusCode,
message: serverError?.error
)
}
return data
}
public func websocket() -> URL {
@@ -93,6 +104,28 @@ struct RequestBuilder
case post = "POST"
case delete = "DELETE"
}
private struct ServerErrorResponse: Decodable {
let error: String?
}
enum RequestError: Swift.Error, LocalizedError {
case invalidResponse
case httpError(statusCode: Int, message: String?)
var errorDescription: String? {
switch self {
case .invalidResponse:
"The server returned an invalid response."
case .httpError(let statusCode, let message):
if let message, !message.isEmpty {
"Server error (\(statusCode)): \(message)"
} else {
"Server error (\(statusCode)): \(HTTPURLResponse.localizedString(forStatusCode: statusCode))"
}
}
}
}
}
extension Color
+3
View File
@@ -20,12 +20,14 @@ struct AddMediaView: View
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocapitalization(.none)
.autocorrectionDisabled()
.frame(minWidth: 0.0, maxWidth: .infinity)
PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return }
model.fieldContents = contents
}
.labelStyle(.iconOnly)
.fixedSize()
}
}
@@ -98,6 +100,7 @@ struct SearchMediaView: View
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused)
.frame(minWidth: 0.0, maxWidth: .infinity)
if !searchText.isEmpty {
Button {
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
tf.delegate = context.coordinator
tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
tf.autocorrectionType = .no
tf.autocapitalizationType = .none
return tf
@@ -64,6 +66,12 @@ struct AutofocusingTextField: UIViewRepresentable
}
final class FirstResponderTextField: UITextField {
override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.width = UIView.noIntrinsicMetric
return size
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
becomeFirstResponder()
+16 -1
View File
@@ -17,6 +17,7 @@ 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)
@@ -24,7 +25,7 @@ struct ContentView: View
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
NowPlayingView(model: model.nowPlayingViewModel)
.presentationBackground(.regularMaterial)
.presentationDetents([ .height(320.0) ])
.presentationDetents([ .height(390.0) ])
}
.sheet(isPresented: $model.isAddMediaSheetPresented) {
AddMediaView(model: $model.addMediaViewModel)
@@ -75,6 +76,9 @@ extension ContentView
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.playlistModel.isPlaying = !nowPlaying.isPaused
model.favoritesModel.isPlaying = !nowPlaying.isPaused
}
@@ -108,6 +112,17 @@ 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 }
+8 -1
View File
@@ -77,6 +77,14 @@ class MainViewModel
nowPlayingViewModel.onPrev = apiCallback { _, api in
try await api.previous()
}
nowPlayingViewModel.onSeek = { [weak self] model, time in
Task {
await self?.withModificationsViaAPI { api in
try await api.seek(to: time)
}
}
}
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
self?.isNowPlayingSheetPresented = false
@@ -411,4 +419,3 @@ extension View {
modifier(ErrorDisplayModifier(error: error))
}
}
+36 -26
View File
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
var body: some View {
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
let tapGesture = DragGesture(minimumDistance: 0)
.updating($tapGestureState) { _, state, _ in
state = true
@@ -28,41 +29,50 @@ struct NowPlayingMiniView: View {
onTap()
}
HStack {
VStack(alignment: .leading) {
if let title = model.title, !title.isEmpty {
Text(title)
.font(.caption)
.lineLimit(1)
.bold()
VStack(spacing: 0.0) {
HStack {
VStack(alignment: .leading) {
if let title = model.title, !title.isEmpty {
Text(title)
.font(.caption)
.lineLimit(1)
.bold()
}
if let subtitle = model.subtitle, !subtitle.isEmpty {
Text(subtitle)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
if nothingQueued {
Text(.notPlaying)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let subtitle = model.subtitle, !subtitle.isEmpty {
Text(subtitle)
.lineLimit(1)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if nothingQueued {
Text(.notPlaying)
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
.imageScale(.large)
.padding(12.0)
}
.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)
}
Spacer()
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
.imageScale(.large)
.padding(12.0)
}
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
.background(
RoundedRectangle(cornerRadius: 12)
containerShape
.fill(tapGestureState ? .ultraThinMaterial : .bar)
.stroke(.ultraThinMaterial, lineWidth: 1.0)
)
.clipShape(containerShape)
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
.gesture(tapGesture)
}
+79 -1
View File
@@ -5,6 +5,7 @@
// Created by James Magahern on 3/3/25.
//
import Foundation
import SwiftUI
@Observable
@@ -14,17 +15,40 @@ class NowPlayingViewModel
var onStop: (NowPlayingViewModel) -> Void = { _ in }
var onNext: (NowPlayingViewModel) -> Void = { _ in }
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
var onSeek: (NowPlayingViewModel, Double) -> Void = { _, _ in }
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
var isPlaying: Bool = false
var title: String? = ""
var subtitle: String? = ""
var volume: Double = 0.5
var timePosition: Double?
var duration: Double?
var isSeekable: Bool = false
fileprivate var isSettingVolume: Bool = false
fileprivate var settingVolume: Double = 0.0 {
didSet { volume = settingVolume }
}
fileprivate var isSettingPlaybackPosition: Bool = false
fileprivate var settingPlaybackPosition: Double = 0.0
var playbackDuration: Double? {
guard let duration, duration.isFinite, duration > 0 else { return nil }
return duration
}
var displayedPlaybackPosition: Double {
let position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
guard position.isFinite else { return 0.0 }
return min(max(position, 0.0), playbackDuration ?? position)
}
var playbackProgress: Double? {
guard let playbackDuration else { return nil }
return displayedPlaybackPosition / playbackDuration
}
}
struct NowPlayingView: View
@@ -60,7 +84,29 @@ struct NowPlayingView: View
}
}
Spacer(minLength: 24.0)
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)
HStack {
Text(formatTime(model.displayedPlaybackPosition))
Spacer()
Text(formatTime(duration))
}
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 4.0)
Spacer(minLength: 20.0)
}
VStack {
HStack {
@@ -123,6 +169,38 @@ struct NowPlayingView: View
}
}
}
private var playbackPositionBinding: Binding<Double> {
Binding(
get: { model.displayedPlaybackPosition },
set: { model.settingPlaybackPosition = $0 }
)
}
private func playbackPositionEditingChanged(_ editing: Bool) {
if editing {
model.settingPlaybackPosition = model.displayedPlaybackPosition
model.isSettingPlaybackPosition = true
} else if model.isSettingPlaybackPosition {
let position = model.settingPlaybackPosition
model.timePosition = position
model.isSettingPlaybackPosition = false
model.onSeek(model, position)
}
}
private func formatTime(_ time: Double) -> String {
let totalSeconds = max(Int(time), 0)
let hours = totalSeconds / 3_600
let minutes = (totalSeconds % 3_600) / 60
let seconds = totalSeconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
// MARK: - Types