// // MainView.swift // QueueCube // // Created by James Magahern on 6/10/25. // import SwiftUI @Observable class MainViewModel { var selectedServer: Server? = Settings.fromDefaults().selectedServer var connectionError: Error? = nil var selectedTab: Tab = .playlist var isNowPlayingSheetPresented: Bool = false var isAddMediaSheetPresented: Bool = false var isEditSheetPresented: Bool = false var playlistModel = MediaListViewModel(mode: .playlist) var favoritesModel = MediaListViewModel(mode: .favorites) var nowPlayingViewModel = NowPlayingViewModel() var addMediaViewModel = AddMediaView.ViewModel() var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel() var editMediaViewModel = EditItemViewModel() private var refreshingFromAPIDepth: UInt8 = 0 private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 } enum Tab: String, CaseIterable { case playlist case favorites case settings } init() { observePlaylistChanges() observeNowPlayingModel() configureViewModelCallbacks() } func onAddButtonTapped() { isAddMediaSheetPresented = true } func onNowPlayingMiniTapped() { isNowPlayingSheetPresented = true } func reset() async { await withModificationsViaAPI { _ in playlistModel = MediaListViewModel(mode: .playlist) favoritesModel = MediaListViewModel(mode: .favorites) nowPlayingViewModel = NowPlayingViewModel() } configureViewModelCallbacks() } func configureViewModelCallbacks() { // Now Playing nowPlayingViewModel.onPlayPause = apiCallback { model, api in model.isPlaying ? try await api.pause() : try await api.play() } nowPlayingViewModel.onStop = apiCallback { model, api in try await api.stop() } nowPlayingViewModel.onNext = apiCallback { _, api in try await api.skip() } nowPlayingViewModel.onPrev = apiCallback { _, api in try await api.previous() } nowPlayingViewModel.onSheetDismiss = { [weak self] _ in self?.isNowPlayingSheetPresented = false } // Playlist playlistModel.onSeek = apiCallback { item, api in if let index = item.index { try await api.skip(index) } } playlistModel.onFavorite = apiCallback { item, api in try await api.addFavorite(mediaURL: item.filename) } // Favorites favoritesModel.onPlay = apiCallback { item, api in try await api.replace(mediaURL: item.filename) try await api.play() } favoritesModel.onEdit = { [weak self] item in guard let self else { return } editMediaViewModel.mediaURL = item.filename editMediaViewModel.title = item.title isEditSheetPresented = true } favoritesModel.onQueue = apiCallback { item, api in try await api.add(mediaURL: item.filename) } // Edit editMediaViewModel.onCancel = { [weak self] _ in self?.isEditSheetPresented = false } editMediaViewModel.onDone = apiCallback { [weak self] model, api in self?.isEditSheetPresented = false try await api.renameFavorite(mediaURL: model.mediaURL, title: model.title) } // Add Media addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in guard let self else { return } let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines) if !strippedURL.isEmpty { addMediaViewModel.fieldContents = "" isAddMediaSheetPresented = false switch selectedTab { case .playlist: try await api.add(mediaURL: strippedURL) case .favorites: try await api.addFavorite(mediaURL: strippedURL) case .settings: break } } } addMediaViewModel.onCancel = { [weak self] in self?.isAddMediaSheetPresented = false } } func observeNowPlayingModel() { withObservationTracking { _ = nowPlayingViewModel.volume } onChange: { [weak self] in guard let self else { return } let isRefreshing = isRefreshingFromAPI Task { if !isRefreshing { await self.withModificationsViaAPI { api in try await api.setVolume(self.nowPlayingViewModel.volume) } } await MainActor.run { self.observeNowPlayingModel() } } } } func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async { guard let api = selectedServer?.api else { return } refreshingFromAPIDepth += 1 do { try await modificationBlock(api) connectionError = nil } catch { print("Error refreshing content: \(error)") connectionError = error } refreshingFromAPIDepth -= 1 } private func apiCallback(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void { return { t in Task { await self.withModificationsViaAPI { try await f(t, $0) } } } } private func observePlaylistChanges() { withObservationTracking { _ = playlistModel.items _ = favoritesModel.items } onChange: { [weak self] in guard let self else { return } let isRefreshing = isRefreshingFromAPI let oldPlaylist = playlistModel.items let oldFavorites = favoritesModel.items Task { @MainActor [weak self] in guard let self else { return } if !isRefreshing { // Notify server of removals let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id } await withModificationsViaAPI { api in for removal in playlistDiff.removals { switch removal { case .remove(let offset, _, _): try await api.delete(index: offset) default: break } } } let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id } await withModificationsViaAPI { api in for removal in favoritesDiff.removals { switch removal { case .remove(_, let favorite, _): try await api.deleteFavorite(mediaURL: favorite.filename) default: break } } } } observePlaylistChanges() } } } } struct MainView: View { @Binding var model: MainViewModel @State var isSettingsVisible: Bool = false init(model: Binding) { self._model = model // If no servers are configured, make Settings the default tab. if !Settings.fromDefaults().isConfigured { model.wrappedValue.selectedTab = .settings } } var body: some View { TabView(selection: $model.selectedTab) { Tab(.playlist, systemImage: "list.bullet", value: .playlist) { NavigationStack { MediaListView(model: $model.playlistModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() } .displayingError(model.connectionError) .withAddButton { model.onAddButtonTapped() } .navigationTitle(.playlist) } } Tab(.favorites, systemImage: "heart.fill", value: .favorites) { NavigationStack { MediaListView(model: $model.favoritesModel) .displayingServerSelectionToolbar(model: $model.serverSelectionViewModel) .displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() } .displayingError(model.connectionError) .withAddButton { model.onAddButtonTapped() } .navigationTitle(.favorites) } } Tab(.settings, systemImage: "gear", value: .settings) { SettingsView(onDone: {}) } } .tabViewStyle(.sidebarAdaptable) } } struct NowPlayingMiniPlayerModifier: ViewModifier { let onTap: () -> Void @Binding var model: NowPlayingViewModel @State var nowPlayingHeight: CGFloat = 0.0 func body(content: Content) -> some View { ZStack { content .safeAreaPadding(.bottom, nowPlayingHeight) VStack { Spacer() NowPlayingMiniView(model: $model, onTap: onTap) .padding() .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 800.0) .onGeometryChange(for: CGSize.self) { $0.size } action: { nowPlayingHeight = $0.height } } } } } struct ServerSelectionToolbarModifier: ViewModifier { @Binding var model: ViewModel func body(content: Content) -> some View { content .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Menu { Section { ForEach(model.selectableServers) { server in Button { model.selectedServer = server } label: { Text(server.displayName) if model.selectedServer == server { Image(systemName: "checkmark") } } } } } label: { Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down") .labelStyle(.titleOnly) } .buttonBorderShape(.capsule) .buttonStyle(.bordered) .menuStyle(.button) } } } // MARK: - Types @Observable class ViewModel { var selectableServers: [Server] = Settings.fromDefaults().configuredServers var selectedServer: Server? = Settings.fromDefaults().selectedServer { didSet { Settings .fromDefaults() .selectedServer(selectedServer) .save() } } } } struct AddButtonToolbarModifier: ViewModifier { let onAdd: () -> Void func body(content: Content) -> some View { content .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button { onAdd() } label: { Image(systemName: "plus") } } } } } struct ErrorDisplayModifier: ViewModifier { let error: Error? func body(content: Content) -> some View { content .overlay { if error != nil { ZStack { Rectangle() .fill(.background) contentPlaceholderView( title: .connectionError, subtitle: error?.localizedDescription, systemImage: "exclamationmark.triangle.fill" ).tint(.label) } } } } } extension View { func displayingServerSelectionToolbar(model: Binding) -> some View { modifier(ServerSelectionToolbarModifier(model: model)) } func displayingNowPlayingMiniPlayer(model: Binding, onTap: @escaping () -> Void) -> some View { modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model)) } func withAddButton(onAdd: @escaping () -> Void) -> some View { modifier(AddButtonToolbarModifier(onAdd: onAdd)) } func displayingError(_ error: Error?) -> some View { modifier(ErrorDisplayModifier(error: error)) } }