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 playingItem: MediaItem?
|
||||||
let isPaused: Bool
|
let isPaused: Bool
|
||||||
let volume: Int
|
let volume: Int
|
||||||
|
let timePosition: Double?
|
||||||
|
let duration: Double?
|
||||||
|
let seekable: Bool?
|
||||||
}
|
}
|
||||||
|
|
||||||
actor API
|
actor API
|
||||||
@@ -172,6 +175,13 @@ actor API
|
|||||||
.body([ "volume" : Int(value * 100) ])
|
.body([ "volume" : Int(value * 100) ])
|
||||||
.post()
|
.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]> {
|
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
||||||
try await request()
|
try await request()
|
||||||
@@ -255,10 +265,19 @@ actor API
|
|||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
|
|
||||||
enum Error: Swift.Error
|
enum Error: Swift.Error, LocalizedError
|
||||||
{
|
{
|
||||||
case apiNotConfigured
|
case apiNotConfigured
|
||||||
case websocketError(Swift.Error)
|
case websocketError(Swift.Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .apiNotConfigured:
|
||||||
|
"No server is configured."
|
||||||
|
case .websocketError(let error):
|
||||||
|
error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum StreamEvent {
|
enum StreamEvent {
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ struct RequestBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func json<T: Decodable>() async throws -> T {
|
public func json<T: Decodable>() async throws -> T {
|
||||||
let urlRequest = self.build()
|
let data = try await responseData()
|
||||||
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
|
||||||
return try JSONDecoder().decode(T.self, from: data)
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +70,24 @@ struct RequestBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func execute() async throws {
|
public func execute() async throws {
|
||||||
let urlRequest = self.build()
|
_ = try await responseData()
|
||||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
}
|
||||||
if let httpResponse = response as? HTTPURLResponse {
|
|
||||||
if httpResponse.statusCode != 200 {
|
private func responseData() async throws -> Data {
|
||||||
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)")
|
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 {
|
public func websocket() -> URL {
|
||||||
@@ -93,6 +103,28 @@ struct RequestBuilder
|
|||||||
case post = "POST"
|
case post = "POST"
|
||||||
case delete = "DELETE"
|
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
|
extension Color
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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)
|
||||||
@@ -24,7 +25,7 @@ struct ContentView: View
|
|||||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||||
NowPlayingView(model: model.nowPlayingViewModel)
|
NowPlayingView(model: model.nowPlayingViewModel)
|
||||||
.presentationBackground(.regularMaterial)
|
.presentationBackground(.regularMaterial)
|
||||||
.presentationDetents([ .height(320.0) ])
|
.presentationDetents([ .height(390.0) ])
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||||
AddMediaView(model: $model.addMediaViewModel)
|
AddMediaView(model: $model.addMediaViewModel)
|
||||||
@@ -75,6 +76,9 @@ extension ContentView
|
|||||||
|
|
||||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
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.duration = nowPlaying.duration
|
||||||
|
model.nowPlayingViewModel.isSeekable = nowPlaying.seekable ?? false
|
||||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||||
model.favoritesModel.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 {
|
private func watchWebsocket() async {
|
||||||
guard let api = model.selectedServer?.api else { return }
|
guard let api = model.selectedServer?.api else { return }
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ class MainViewModel
|
|||||||
nowPlayingViewModel.onPrev = apiCallback { _, api in
|
nowPlayingViewModel.onPrev = apiCallback { _, api in
|
||||||
try await api.previous()
|
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
|
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||||
self?.isNowPlayingSheetPresented = false
|
self?.isNowPlayingSheetPresented = false
|
||||||
@@ -411,4 +419,3 @@ extension View {
|
|||||||
modifier(ErrorDisplayModifier(error: error))
|
modifier(ErrorDisplayModifier(error: error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,36 +28,44 @@ struct NowPlayingMiniView: View {
|
|||||||
onTap()
|
onTap()
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
VStack(spacing: 0.0) {
|
||||||
VStack(alignment: .leading) {
|
HStack {
|
||||||
if let title = model.title, !title.isEmpty {
|
VStack(alignment: .leading) {
|
||||||
Text(title)
|
if let title = model.title, !title.isEmpty {
|
||||||
.font(.caption)
|
Text(title)
|
||||||
.lineLimit(1)
|
.font(.caption)
|
||||||
.bold()
|
.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 {
|
Spacer()
|
||||||
Text(subtitle)
|
|
||||||
.lineLimit(1)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
if nothingQueued {
|
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||||
Text(.notPlaying)
|
.imageScale(.large)
|
||||||
.font(.caption)
|
.padding(12.0)
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
}
|
.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(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by James Magahern on 3/3/25.
|
// Created by James Magahern on 3/3/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -14,17 +15,40 @@ class NowPlayingViewModel
|
|||||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
var onSeek: (NowPlayingViewModel, Double) -> Void = { _, _ in }
|
||||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||||
|
|
||||||
var isPlaying: Bool = false
|
var isPlaying: Bool = false
|
||||||
var title: String? = ""
|
var title: String? = ""
|
||||||
var subtitle: String? = ""
|
var subtitle: String? = ""
|
||||||
var volume: Double = 0.5
|
var volume: Double = 0.5
|
||||||
|
var timePosition: Double?
|
||||||
|
var duration: Double?
|
||||||
|
var isSeekable: Bool = false
|
||||||
|
|
||||||
fileprivate var isSettingVolume: Bool = false
|
fileprivate var isSettingVolume: Bool = false
|
||||||
fileprivate var settingVolume: Double = 0.0 {
|
fileprivate var settingVolume: Double = 0.0 {
|
||||||
didSet { volume = settingVolume }
|
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
|
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 {
|
VStack {
|
||||||
HStack {
|
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
|
// MARK: - Types
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user