Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71142e99fc | ||
|
|
b47d60c17f |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user