Files
QueueCube/ios/QueueCube/Views/ContentView.swift
James Magahern b47d60c17f
All checks were successful
TestFlight / testflight (push) Successful in 1m22s
ios: add playback progress and error reporting
2026-07-19 00:02:54 -07:00

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()
}
}
}