- Adds paste button - Fix autofocus behavior (using UIKit) - Remove pointless sheet detents
415 lines
14 KiB
Swift
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))
|
|
}
|
|
}
|
|
|