ios: add playback progress and error reporting
All checks were successful
TestFlight / testflight (push) Successful in 1m22s

This commit is contained in:
2026-07-19 00:02:54 -07:00
parent d6d280338d
commit b47d60c17f
6 changed files with 196 additions and 37 deletions

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 {

View File

@@ -61,8 +61,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 +70,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 +103,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

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 }

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))
}
}

View File

@@ -28,36 +28,44 @@ 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)
.fill(tapGestureState ? .ultraThinMaterial : .bar)

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