All checks were successful
TestFlight / testflight (push) Successful in 1m22s
220 lines
8.2 KiB
Swift
220 lines
8.2 KiB
Swift
//
|
|
// ContentView.swift
|
|
// QueueCube
|
|
//
|
|
// Created by James Magahern on 3/3/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ContentView: View
|
|
{
|
|
@State var model = MainViewModel()
|
|
@State private var websocketRestartTrigger = 0
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
var body: some 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)
|
|
}
|
|
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
|
NowPlayingView(model: model.nowPlayingViewModel)
|
|
.presentationBackground(.regularMaterial)
|
|
.presentationDetents([ .height(390.0) ])
|
|
}
|
|
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
|
AddMediaView(model: $model.addMediaViewModel)
|
|
.presentationBackground(.regularMaterial)
|
|
}
|
|
.sheet(isPresented: $model.isEditSheetPresented) {
|
|
EditItemView(model: $model.editMediaViewModel)
|
|
.presentationBackground(.regularMaterial)
|
|
}
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
struct RefreshType: OptionSet
|
|
{
|
|
let rawValue: Int
|
|
|
|
static let nowPlaying = RefreshType(rawValue: 1 << 0)
|
|
static let playlist = RefreshType(rawValue: 1 << 1)
|
|
static let favorites = RefreshType(rawValue: 1 << 2)
|
|
}
|
|
}
|
|
|
|
extension ContentView
|
|
{
|
|
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
|
|
// When app returns to active state from background, force reconnect and refresh
|
|
if newPhase == .active {
|
|
Task {
|
|
// Force WebSocket reconnection
|
|
websocketRestartTrigger += 1
|
|
|
|
// Give the WebSocket a moment to reconnect
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Full UI refresh
|
|
await refresh([.nowPlaying, .playlist, .favorites])
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refresh(_ what: RefreshType) async {
|
|
await model.withModificationsViaAPI { api in
|
|
if what.contains(.nowPlaying) {
|
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
|
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
|
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
|
|
|
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
|
|
}
|
|
|
|
if what.contains(.playlist) {
|
|
let playlist = try await api.fetchPlaylist()
|
|
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
|
MediaListItem(
|
|
id: String(mediaItem.id),
|
|
title: mediaItem.displayTitle,
|
|
filename: mediaItem.filename ?? "<null>",
|
|
index: idx,
|
|
isCurrent: mediaItem.current ?? false,
|
|
playbackError: mediaItem.playbackError
|
|
)
|
|
}
|
|
}
|
|
|
|
if what.contains(.favorites) {
|
|
let favorites = try await api.fetchFavorites()
|
|
let nowPlaying = try await api.fetchNowPlayingInfo()
|
|
model.favoritesModel.items = favorites.map { mediaItem in
|
|
MediaListItem(
|
|
id: String(mediaItem.id),
|
|
title: mediaItem.displayTitle,
|
|
filename: mediaItem.filename ?? "<null>",
|
|
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename,
|
|
playbackError: mediaItem.playbackError
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
|
|
do {
|
|
for await streamEvent in try await api.events() {
|
|
switch streamEvent {
|
|
case .event(let event):
|
|
await clearConnectionErrorIfNecessary()
|
|
await handle(event: event)
|
|
case .error(let error):
|
|
// Ignore if we're in the bg
|
|
guard scenePhase == .active else { break }
|
|
|
|
// Check if this is a backgrounding error (connection abort)
|
|
var isBackgroundingError = false
|
|
if case let .websocketError(wsError) = error {
|
|
let nsError = wsError as NSError
|
|
isBackgroundingError = nsError.code == 53
|
|
}
|
|
|
|
// Only show connection error to user if it's not a backgrounding error
|
|
if !isBackgroundingError {
|
|
model.connectionError = error
|
|
}
|
|
|
|
// Always attempt reconnection after a delay
|
|
Task { @MainActor in
|
|
try await Task.sleep(for: .seconds(1.0))
|
|
websocketRestartTrigger += 1
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
} catch {
|
|
print("Events error: \(error)")
|
|
}
|
|
}
|
|
|
|
private func handle(event: API.Event) async {
|
|
switch event.type {
|
|
case .volumeUpdate: fallthrough
|
|
case .nowPlayingUpdate:
|
|
await refresh(.nowPlaying)
|
|
|
|
case .playlistUpdate:
|
|
await refresh(.playlist)
|
|
|
|
case .favoritesUpdate:
|
|
await refresh(.favorites)
|
|
|
|
case .websocketReconnected: fallthrough
|
|
case .metadataUpdate: fallthrough
|
|
case .mpdUpdate: fallthrough
|
|
case .playbackError:
|
|
await refresh([.playlist, .nowPlaying, .favorites])
|
|
|
|
case .receivedWebsocketPong:
|
|
// This means we're online.
|
|
await clearConnectionErrorIfNecessary()
|
|
}
|
|
}
|
|
|
|
private func clearConnectionErrorIfNecessary() async {
|
|
if model.connectionError != nil {
|
|
model.connectionError = nil
|
|
await refresh([.playlist, .nowPlaying, .favorites])
|
|
}
|
|
}
|
|
|
|
private func watchForSettingsChanges() async {
|
|
let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged)
|
|
.map({ _ in Optional.none })
|
|
|
|
for await _ in settingsChangedNotifications {
|
|
let newSelectedServer = Settings.fromDefaults().selectedServer
|
|
if newSelectedServer != model.selectedServer {
|
|
model.selectedServer = newSelectedServer
|
|
|
|
// Reset view model to defaults
|
|
await model.reset()
|
|
|
|
// Restart WebSocket connection for new server
|
|
websocketRestartTrigger += 1
|
|
|
|
await refresh([.playlist, .nowPlaying, .favorites])
|
|
}
|
|
|
|
// Always reset this
|
|
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
|
}
|
|
}
|
|
}
|