diff --git a/ios/QueueCube/Backend/API.swift b/ios/QueueCube/Backend/API.swift index 743b295..ce250f0 100644 --- a/ios/QueueCube/Backend/API.swift +++ b/ios/QueueCube/Backend/API.swift @@ -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 { diff --git a/ios/QueueCube/Backend/Utilities.swift b/ios/QueueCube/Backend/Utilities.swift index f4d4d00..76fb674 100644 --- a/ios/QueueCube/Backend/Utilities.swift +++ b/ios/QueueCube/Backend/Utilities.swift @@ -61,8 +61,7 @@ struct RequestBuilder } public func json() 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 diff --git a/ios/QueueCube/Views/ContentView.swift b/ios/QueueCube/Views/ContentView.swift index 87df5ac..b9ae88c 100644 --- a/ios/QueueCube/Views/ContentView.swift +++ b/ios/QueueCube/Views/ContentView.swift @@ -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 } diff --git a/ios/QueueCube/Views/MainView.swift b/ios/QueueCube/Views/MainView.swift index f69b807..619070f 100644 --- a/ios/QueueCube/Views/MainView.swift +++ b/ios/QueueCube/Views/MainView.swift @@ -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)) } } - diff --git a/ios/QueueCube/Views/NowPlayingMiniView.swift b/ios/QueueCube/Views/NowPlayingMiniView.swift index bcb57c1..55b89b1 100644 --- a/ios/QueueCube/Views/NowPlayingMiniView.swift +++ b/ios/QueueCube/Views/NowPlayingMiniView.swift @@ -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) diff --git a/ios/QueueCube/Views/NowPlayingView.swift b/ios/QueueCube/Views/NowPlayingView.swift index 65e34b8..3eba125 100644 --- a/ios/QueueCube/Views/NowPlayingView.swift +++ b/ios/QueueCube/Views/NowPlayingView.swift @@ -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 { + 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