ios: add playback progress and error reporting
All checks were successful
TestFlight / testflight (push) Successful in 1m22s
All checks were successful
TestFlight / testflight (push) Successful in 1m22s
This commit is contained in:
@@ -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
|
||||
@@ -173,6 +176,13 @@ actor API
|
||||
.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()
|
||||
.pathString("/search?q=\(query.uriEncoded())")
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -109,6 +113,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 }
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ class MainViewModel
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ struct NowPlayingMiniView: View {
|
||||
onTap()
|
||||
}
|
||||
|
||||
VStack(spacing: 0.0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
@@ -58,6 +59,13 @@ struct NowPlayingMiniView: View {
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
|
||||
@@ -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 {
|
||||
@@ -124,6 +170,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
|
||||
|
||||
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||
|
||||
Reference in New Issue
Block a user