Files
QueueCube/ios/QueueCube/Views/MainView.swift
James Magahern 3aa819eccc ios: Fixes for add media view
- Adds paste button
- Fix autofocus behavior (using UIKit)
- Remove pointless sheet detents
2025-11-15 17:42:00 -08:00

415 lines
14 KiB
Swift

//
// 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<T>(_ 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<MainViewModel>) {
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<ServerSelectionToolbarModifier.ViewModel>) -> some View {
modifier(ServerSelectionToolbarModifier(model: model))
}
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, 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))
}
}