Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366aa3b723 | ||
|
|
71142e99fc |
@@ -50,8 +50,9 @@ struct RequestBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func build() -> URLRequest {
|
public func build() -> URLRequest {
|
||||||
var request = URLRequest(url: self.url)
|
var request = URLRequest(url: self.url, cachePolicy: .reloadIgnoringLocalCacheData)
|
||||||
request.httpMethod = self.httpMethod.rawValue
|
request.httpMethod = self.httpMethod.rawValue
|
||||||
|
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
||||||
if let body {
|
if let body {
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ struct AddMediaView: View
|
|||||||
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
|
||||||
.autocapitalization(.none)
|
.autocapitalization(.none)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.frame(minWidth: 0.0, maxWidth: .infinity)
|
||||||
|
|
||||||
PasteButton(payloadType: String.self) { payload in
|
PasteButton(payloadType: String.self) { payload in
|
||||||
guard let contents = payload.first else { return }
|
guard let contents = payload.first else { return }
|
||||||
model.fieldContents = contents
|
model.fieldContents = contents
|
||||||
}
|
}
|
||||||
.labelStyle(.iconOnly)
|
.labelStyle(.iconOnly)
|
||||||
|
.fixedSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ struct SearchMediaView: View
|
|||||||
|
|
||||||
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
|
AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
|
||||||
.focused($searchFieldFocused)
|
.focused($searchFieldFocused)
|
||||||
|
.frame(minWidth: 0.0, maxWidth: .infinity)
|
||||||
|
|
||||||
if !searchText.isEmpty {
|
if !searchText.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
|
|||||||
tf.delegate = context.coordinator
|
tf.delegate = context.coordinator
|
||||||
tf.returnKeyType = .done
|
tf.returnKeyType = .done
|
||||||
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
tf.autocorrectionType = .no
|
tf.autocorrectionType = .no
|
||||||
tf.autocapitalizationType = .none
|
tf.autocapitalizationType = .none
|
||||||
return tf
|
return tf
|
||||||
@@ -64,6 +66,12 @@ struct AutofocusingTextField: UIViewRepresentable
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class FirstResponderTextField: UITextField {
|
final class FirstResponderTextField: UITextField {
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
var size = super.intrinsicContentSize
|
||||||
|
size.width = UIView.noIntrinsicMetric
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
override func didMoveToSuperview() {
|
||||||
super.didMoveToSuperview()
|
super.didMoveToSuperview()
|
||||||
becomeFirstResponder()
|
becomeFirstResponder()
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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)
|
||||||
@@ -74,11 +73,13 @@ extension ContentView
|
|||||||
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
||||||
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
||||||
|
|
||||||
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.syncPlaybackPosition(
|
||||||
model.nowPlayingViewModel.duration = nowPlaying.duration
|
nowPlaying.timePosition,
|
||||||
model.nowPlayingViewModel.isSeekable = nowPlaying.seekable ?? false
|
duration: nowPlaying.duration,
|
||||||
|
isPlaying: !nowPlaying.isPaused,
|
||||||
|
isSeekable: nowPlaying.seekable ?? false
|
||||||
|
)
|
||||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||||
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||||
}
|
}
|
||||||
@@ -113,17 +114,6 @@ 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 }
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct NowPlayingMiniView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||||
|
let containerShape = RoundedRectangle(cornerRadius: 12.0, style: .continuous)
|
||||||
let tapGesture = DragGesture(minimumDistance: 0)
|
let tapGesture = DragGesture(minimumDistance: 0)
|
||||||
.updating($tapGestureState) { _, state, _ in
|
.updating($tapGestureState) { _, state, _ in
|
||||||
state = true
|
state = true
|
||||||
@@ -60,17 +61,20 @@ struct NowPlayingMiniView: View {
|
|||||||
}
|
}
|
||||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||||
|
|
||||||
if let progress = model.playbackProgress {
|
if model.playbackDuration != nil {
|
||||||
ProgressView(value: progress)
|
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||||
.progressViewStyle(.linear)
|
ProgressView(value: model.playbackProgress(at: context.date) ?? 0.0)
|
||||||
.tint(.accentColor)
|
.progressViewStyle(.linear)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
containerShape
|
||||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||||
)
|
)
|
||||||
|
.clipShape(containerShape)
|
||||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||||
.gesture(tapGesture)
|
.gesture(tapGesture)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class NowPlayingViewModel
|
|||||||
|
|
||||||
fileprivate var isSettingPlaybackPosition: Bool = false
|
fileprivate var isSettingPlaybackPosition: Bool = false
|
||||||
fileprivate var settingPlaybackPosition: Double = 0.0
|
fileprivate var settingPlaybackPosition: Double = 0.0
|
||||||
|
private var playbackPositionSyncedAt = Date.now
|
||||||
|
|
||||||
var playbackDuration: Double? {
|
var playbackDuration: Double? {
|
||||||
guard let duration, duration.isFinite, duration > 0 else { return nil }
|
guard let duration, duration.isFinite, duration > 0 else { return nil }
|
||||||
@@ -40,14 +41,35 @@ class NowPlayingViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayedPlaybackPosition: Double {
|
var displayedPlaybackPosition: Double {
|
||||||
let position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
|
displayedPlaybackPosition(at: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayedPlaybackPosition(at date: Date) -> Double {
|
||||||
|
var position = isSettingPlaybackPosition ? settingPlaybackPosition : (timePosition ?? 0.0)
|
||||||
|
if isPlaying && !isSettingPlaybackPosition {
|
||||||
|
position += max(date.timeIntervalSince(playbackPositionSyncedAt), 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
guard position.isFinite else { return 0.0 }
|
guard position.isFinite else { return 0.0 }
|
||||||
return min(max(position, 0.0), playbackDuration ?? position)
|
return min(max(position, 0.0), playbackDuration ?? position)
|
||||||
}
|
}
|
||||||
|
|
||||||
var playbackProgress: Double? {
|
func playbackProgress(at date: Date) -> Double? {
|
||||||
guard let playbackDuration else { return nil }
|
guard let playbackDuration else { return nil }
|
||||||
return displayedPlaybackPosition / playbackDuration
|
return displayedPlaybackPosition(at: date) / playbackDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncPlaybackPosition(_ position: Double?, duration: Double?, isPlaying: Bool, isSeekable: Bool) {
|
||||||
|
timePosition = position
|
||||||
|
self.duration = duration
|
||||||
|
self.isPlaying = isPlaying
|
||||||
|
self.isSeekable = isSeekable
|
||||||
|
playbackPositionSyncedAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func seekOptimistically(to position: Double) {
|
||||||
|
timePosition = position
|
||||||
|
playbackPositionSyncedAt = .now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,21 +109,23 @@ struct NowPlayingView: View
|
|||||||
Spacer(minLength: 20.0)
|
Spacer(minLength: 20.0)
|
||||||
|
|
||||||
if let duration = model.playbackDuration {
|
if let duration = model.playbackDuration {
|
||||||
VStack(spacing: 2.0) {
|
TimelineView(.periodic(from: .now, by: 1.0)) { context in
|
||||||
Slider(
|
VStack(spacing: 2.0) {
|
||||||
value: playbackPositionBinding,
|
Slider(
|
||||||
in: 0.0...duration,
|
value: playbackPositionBinding(at: context.date),
|
||||||
onEditingChanged: playbackPositionEditingChanged
|
in: 0.0...duration,
|
||||||
)
|
onEditingChanged: playbackPositionEditingChanged
|
||||||
.disabled(!model.isSeekable || nothingQueued)
|
)
|
||||||
|
.disabled(!model.isSeekable || nothingQueued)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(formatTime(model.displayedPlaybackPosition))
|
Text(formatTime(model.displayedPlaybackPosition(at: context.date)))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(formatTime(duration))
|
Text(formatTime(duration))
|
||||||
|
}
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.font(.caption.monospacedDigit())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4.0)
|
.padding(.horizontal, 4.0)
|
||||||
|
|
||||||
@@ -170,9 +194,9 @@ struct NowPlayingView: View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var playbackPositionBinding: Binding<Double> {
|
private func playbackPositionBinding(at date: Date) -> Binding<Double> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { model.displayedPlaybackPosition },
|
get: { model.displayedPlaybackPosition(at: date) },
|
||||||
set: { model.settingPlaybackPosition = $0 }
|
set: { model.settingPlaybackPosition = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -183,7 +207,7 @@ struct NowPlayingView: View
|
|||||||
model.isSettingPlaybackPosition = true
|
model.isSettingPlaybackPosition = true
|
||||||
} else if model.isSettingPlaybackPosition {
|
} else if model.isSettingPlaybackPosition {
|
||||||
let position = model.settingPlaybackPosition
|
let position = model.settingPlaybackPosition
|
||||||
model.timePosition = position
|
model.seekOptimistically(to: position)
|
||||||
model.isSettingPlaybackPosition = false
|
model.isSettingPlaybackPosition = false
|
||||||
model.onSeek(model, position)
|
model.onSeek(model, position)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user