Add 'ios/' from commit '2220a0d4f2bb0f0ebca509581c21d6c90359bd14'
git-subtree-dir: ios git-subtree-mainline:52968df567git-subtree-split:2220a0d4f2
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// AddMediaView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddMediaView: View
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
@FocusState var fieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Add URL
|
||||
Section {
|
||||
TextField(.addAnyURL, text: $model.fieldContents)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.focused($fieldFocused)
|
||||
}
|
||||
|
||||
if model.supportsSearch {
|
||||
Section {
|
||||
NavigationLink {
|
||||
SearchMediaView(model: $model)
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Button(.searchForMedia, action: model.onSearch)
|
||||
}
|
||||
.tint(.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { fieldFocused = true }
|
||||
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
|
||||
.navigationTitle(.addMedia)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button(.add, action: model.addButtonTapped)
|
||||
.disabled(model.fieldContents.isEmpty)
|
||||
.bold()
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
Button(.cancel, action: model.onCancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Page: String, Identifiable
|
||||
{
|
||||
case addURL
|
||||
case searchMedia
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var fieldContents: String = ""
|
||||
var onAdd: (String) -> Void = { _ in }
|
||||
var onCancel: () -> Void = { }
|
||||
var onSearch: () -> Void = { }
|
||||
var supportsSearch: Bool = true
|
||||
|
||||
var activeDetent: PresentationDetent = Detent.collapsed.value
|
||||
|
||||
enum Detent: CaseIterable
|
||||
{
|
||||
case collapsed
|
||||
case expanded
|
||||
|
||||
var value: PresentationDetent {
|
||||
switch self {
|
||||
case .collapsed: .height(320.0)
|
||||
case .expanded: .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func addButtonTapped() {
|
||||
onAdd(fieldContents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchMediaView: View
|
||||
{
|
||||
@Binding var model: AddMediaView.ViewModel
|
||||
@State private var searchModel = SearchModel()
|
||||
@State private var searchText = ""
|
||||
@FocusState private var searchFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search field
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField(.searchForMedia, text: $searchText)
|
||||
.focused($searchFieldFocused)
|
||||
.onSubmit {
|
||||
performSearch()
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
searchModel.displayedResults = []
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
|
||||
if searchModel.isLoading {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView(.searching)
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Spacer()
|
||||
}
|
||||
} else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(.noResultsFound)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
// Results list
|
||||
List(searchModel.displayedResults, id: \.mediaUrl) { item in
|
||||
SearchResultRow(item: item) {
|
||||
model.onAdd(item.mediaUrl)
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
}
|
||||
}
|
||||
.navigationTitle(.searchForMedia)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.onAppear {
|
||||
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
|
||||
searchFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch() {
|
||||
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
searchModel.performSearch(query: searchText)
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultRow: View
|
||||
{
|
||||
let item: SearchResultItem
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
case .failure(_):
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@unknown default:
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(item.author)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(item.type.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.title2)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchMediaView
|
||||
{
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class SearchModel
|
||||
{
|
||||
var displayedResults: [SearchResultItem] = []
|
||||
var isLoading: Bool = false
|
||||
var lastSearchedQuery: String? = nil
|
||||
|
||||
func performSearch(query: String) {
|
||||
guard let api = Settings.fromDefaults().selectedServer?.api else { return }
|
||||
|
||||
isLoading = true
|
||||
lastSearchedQuery = query
|
||||
|
||||
Task {
|
||||
do {
|
||||
let fetchResult = try await api.search(query: query)
|
||||
if let results = fetchResult.results {
|
||||
await MainActor.run {
|
||||
self.displayedResults = results
|
||||
.map { item in
|
||||
// Convert relative thumbnail urls to absolute for loading by AsyncImage
|
||||
var copy = item
|
||||
copy.thumbnailUrl = api.baseURL.absoluteString
|
||||
.replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh...
|
||||
return copy
|
||||
}
|
||||
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.displayedResults = []
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ContentPlaceholderView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentPlaceholderView<Label, Actions>: View
|
||||
where Label: View, Actions: View
|
||||
{
|
||||
let label: Label
|
||||
let actions: Actions
|
||||
|
||||
init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) {
|
||||
self.label = label()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Spacer()
|
||||
|
||||
ContentUnavailableView {
|
||||
label
|
||||
.imageScale(.large)
|
||||
.tint(.secondary)
|
||||
} actions: { actions }
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func contentPlaceholderView<Actions>(
|
||||
title: LocalizedStringKey,
|
||||
subtitle: (any StringProtocol)? = nil,
|
||||
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
|
||||
-> ContentPlaceholderView<AnyView, Actions>
|
||||
{
|
||||
ContentPlaceholderView(label: {
|
||||
AnyView(erasing: VStack(spacing: 16.0) {
|
||||
Image(systemName: systemImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
.frame(width: 50.0, height: 50.0)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.large)
|
||||
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.tint)
|
||||
.bold()
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.tint.opacity(0.5))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(height: 14.0)
|
||||
})
|
||||
}, actions: actions)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//
|
||||
// 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 watchForSettingsChanges() }
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
handleScenePhaseChange(from: oldPhase, to: newPhase)
|
||||
}
|
||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||
NowPlayingView(model: model.nowPlayingViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents([ .height(320.0) ])
|
||||
}
|
||||
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||
AddMediaView(model: $model.addMediaViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents(
|
||||
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
|
||||
selection: $model.addMediaViewModel.activeDetent
|
||||
)
|
||||
}
|
||||
.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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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):
|
||||
// Check if this is a backgrounding error (connection abort)
|
||||
let nsError = error as NSError
|
||||
let 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:
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// EditItemView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/20/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class EditItemViewModel
|
||||
{
|
||||
var mediaURL: String = ""
|
||||
var title: String = ""
|
||||
|
||||
var onDone: (EditItemViewModel) -> Void = { _ in }
|
||||
var onCancel: (EditItemViewModel) -> Void = { _ in }
|
||||
}
|
||||
|
||||
struct EditItemView: View
|
||||
{
|
||||
@Binding var model: EditItemViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(.url) {
|
||||
TextField(.url, text: $model.mediaURL)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true) // editing URL not yet supported by server
|
||||
.contextMenu {
|
||||
Button(.copyURL) {
|
||||
UIPasteboard.general.string = model.mediaURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(.title) {
|
||||
TextField(.title, text: $model.title)
|
||||
}
|
||||
}
|
||||
.navigationTitle(.editItem)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
Button(.cancel, role: .cancel) {
|
||||
model.onCancel(model)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button(.done, role: .destructive) {
|
||||
model.onDone(model)
|
||||
}
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if false
|
||||
// TODO
|
||||
Section {
|
||||
Button(.addServer) {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// NowPlayingMiniView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NowPlayingMiniView: View {
|
||||
@Binding var model: NowPlayingViewModel
|
||||
let onTap: () -> Void
|
||||
|
||||
@GestureState private var tapGestureState = false
|
||||
|
||||
private var nothingQueued: Bool {
|
||||
guard let title = model.title, let subtitle = model.subtitle else { return true }
|
||||
return title.isEmpty && subtitle.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
let tapGesture = DragGesture(minimumDistance: 0)
|
||||
.updating($tapGestureState) { _, state, _ in
|
||||
state = true
|
||||
}
|
||||
.onEnded { _ in
|
||||
onTap()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||
.imageScale(.large)
|
||||
.padding(12.0)
|
||||
}
|
||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||
.gesture(tapGesture)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// NowPlayingView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class NowPlayingViewModel
|
||||
{
|
||||
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var title: String? = ""
|
||||
var subtitle: String? = ""
|
||||
var volume: Double = 0.5
|
||||
|
||||
fileprivate var isSettingVolume: Bool = false
|
||||
fileprivate var settingVolume: Double = 0.0 {
|
||||
didSet { volume = settingVolume }
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView: View
|
||||
{
|
||||
@State var model: NowPlayingViewModel
|
||||
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Spacer()
|
||||
.frame(height: 1.0)
|
||||
|
||||
VStack {
|
||||
if let title = model.title {
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 24.0)
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
ForEach(Buttons.allCases) { button in
|
||||
Spacer()
|
||||
|
||||
Button(action: button.action(model: model)) {
|
||||
Image(systemName: button.imageName(isPlaying: model.isPlaying))
|
||||
.resizable()
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.scaleEffect(button.scale, anchor: .center)
|
||||
.tint(button.tintColor)
|
||||
}
|
||||
.disabled(nothingQueued)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.imageScale(.large)
|
||||
.frame(height: 34.0)
|
||||
.tint(.label)
|
||||
|
||||
Spacer()
|
||||
|
||||
Slider(
|
||||
value: model.isSettingVolume ? $model.settingVolume : $model.volume,
|
||||
in: 0.0...1.0,
|
||||
onEditingChanged: { editing in
|
||||
if model.isSettingVolume != editing {
|
||||
model.settingVolume = model.volume
|
||||
model.isSettingVolume = editing
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 18.0)
|
||||
.padding(.bottom, -12.0) // intrinsic sizing bug workaround?
|
||||
}
|
||||
.padding(.vertical, 44.0)
|
||||
.padding(.horizontal, 12.0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14.0)
|
||||
.fill(.ultraThinMaterial)
|
||||
.stroke(Color.label.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
.padding(.horizontal, 15.0)
|
||||
.padding(.bottom, 10.0)
|
||||
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
model.onSheetDismiss(model)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||
case backward
|
||||
case stop
|
||||
case playPause
|
||||
case forward
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var scale: Double {
|
||||
switch self {
|
||||
case .backward: 0.7
|
||||
case .forward: 0.7
|
||||
case .playPause: 1.0
|
||||
case .stop: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
var tintColor: Color {
|
||||
switch self {
|
||||
case .backward: .label.mix(with: .gray, by: 0.5)
|
||||
case .forward: .label.mix(with: .gray, by: 0.5)
|
||||
case .playPause: .label
|
||||
case .stop: .label
|
||||
}
|
||||
}
|
||||
|
||||
func imageName(isPlaying: Bool) -> String {
|
||||
switch self {
|
||||
case .backward: "backward.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .playPause: isPlaying ? "pause.fill" : "play.fill"
|
||||
case .forward: "forward.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func action(model: NowPlayingViewModel) -> () -> Void {
|
||||
switch self {
|
||||
case .backward: { model.onPrev(model) }
|
||||
case .stop: { model.onStop(model) }
|
||||
case .playPause: { model.onPlayPause(model) }
|
||||
case .forward: { model.onNext(model) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// PlaylistView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaListItem: Identifiable
|
||||
{
|
||||
let _id: String
|
||||
let title: String
|
||||
let filename: String
|
||||
let index: Int?
|
||||
let isCurrent: Bool
|
||||
|
||||
var id: String {
|
||||
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
||||
}
|
||||
|
||||
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) {
|
||||
self._id = id
|
||||
self.title = title
|
||||
self.filename = filename
|
||||
self.index = index
|
||||
self.isCurrent = isCurrent
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaListMode {
|
||||
case playlist
|
||||
case favorites
|
||||
}
|
||||
|
||||
@Observable
|
||||
class MediaListViewModel
|
||||
{
|
||||
let mode: MediaListMode
|
||||
var isPlaying: Bool = false
|
||||
var items: [MediaListItem] = []
|
||||
|
||||
var onSeek: (MediaListItem) -> Void = { _ in }
|
||||
var onPlay: (MediaListItem) -> Void = { _ in }
|
||||
var onQueue: (MediaListItem) -> Void = { _ in }
|
||||
var onEdit: (MediaListItem) -> Void = { _ in }
|
||||
var onFavorite: (MediaListItem) -> Void = { _ in }
|
||||
|
||||
init(mode: MediaListMode) {
|
||||
self.mode = mode
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaListView: View
|
||||
{
|
||||
@Binding var model: MediaListViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if model.items.isEmpty {
|
||||
let title: LocalizedStringKey = switch model.mode {
|
||||
case .playlist: .playlistEmpty
|
||||
case .favorites: .favoritesEmpty
|
||||
}
|
||||
|
||||
contentPlaceholderView(title: title, systemImage: "list.bullet")
|
||||
} else {
|
||||
List($model.items, editActions: .delete) { item in
|
||||
let item = item.wrappedValue
|
||||
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||
|
||||
Button {
|
||||
switch model.mode {
|
||||
case .playlist:
|
||||
model.onSeek(item)
|
||||
case .favorites:
|
||||
model.onPlay(item)
|
||||
}
|
||||
} label: {
|
||||
MediaItemCell(
|
||||
title: item.title,
|
||||
subtitle: item.filename,
|
||||
state: state
|
||||
)
|
||||
}
|
||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
||||
.contextMenu {
|
||||
Button(.copyTitle) {
|
||||
UIPasteboard.general.string = item.title
|
||||
}
|
||||
|
||||
Button(.copyURL) {
|
||||
if let url = URL(string: item.filename) {
|
||||
UIPasteboard.general.url = url
|
||||
} else {
|
||||
UIPasteboard.general.string = item.filename
|
||||
}
|
||||
}
|
||||
|
||||
if model.mode == .favorites {
|
||||
Button(.edit) {
|
||||
model.onEdit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if model.mode == .favorites {
|
||||
Button {
|
||||
model.onQueue(item)
|
||||
} label: {
|
||||
Image(systemName: "plus.square.on.square")
|
||||
Text(.addToQueue)
|
||||
}
|
||||
.tint(.blue)
|
||||
} else if model.mode == .playlist {
|
||||
Button {
|
||||
model.onFavorite(item)
|
||||
} label: {
|
||||
Image(systemName: "star")
|
||||
Text(.favorite)
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct MediaItemCell: View
|
||||
{
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let state: State
|
||||
|
||||
var body: some View {
|
||||
let icon: String = switch state {
|
||||
case .queued: "play.fill"
|
||||
case .playing: "speaker.wave.3.fill"
|
||||
case .paused: "speaker.fill"
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.tint(Color.primary)
|
||||
.frame(width: 15.0)
|
||||
.padding(.trailing, 10.0)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.tint(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitle)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding([.top, .bottom], 4.0)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum State {
|
||||
case queued
|
||||
case playing
|
||||
case paused
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// AddServerView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View
|
||||
{
|
||||
let onAddServer: (Server) -> Void
|
||||
@State var model = ViewModel()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Manual Entry
|
||||
Section(.manual) {
|
||||
TextField(.serverURL, text: $model.serverURL)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
|
||||
switch model.validationState {
|
||||
case .empty:
|
||||
EmptyView()
|
||||
case .validating:
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.validating)
|
||||
}
|
||||
case .notValid:
|
||||
HStack {
|
||||
Image(systemName: "x.circle.fill")
|
||||
Text(.unableToConnect)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
case .valid:
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text(.serverIsOnline)
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Button {
|
||||
// Force unwrap, since we validated it at this point.
|
||||
let server = Server(baseURL: URL(string: model.serverURL)!)
|
||||
onAddServer(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(.addServer)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered
|
||||
Section(.discovered) {
|
||||
if model.discoveredServers.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.findingServers)
|
||||
}
|
||||
} else {
|
||||
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
|
||||
Button {
|
||||
resolveEndpoint(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "network")
|
||||
Text("\(server.displayName)")
|
||||
.bold()
|
||||
|
||||
Spacer()
|
||||
if model.resolvingServers.contains(server) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task {
|
||||
model.startDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
||||
Task {
|
||||
model.resolvingServers.insert(endpoint)
|
||||
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
|
||||
model.resolvingServers.remove(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var serverURL: String = ""
|
||||
var validationURL: String = ""
|
||||
var validationState: ValidationState = .empty
|
||||
|
||||
var discoveredServers: [DiscoveredEndpoint] = []
|
||||
var resolvingServers = Set<DiscoveredEndpoint>()
|
||||
|
||||
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
|
||||
private var validationTimer: Timer? = nil
|
||||
|
||||
init() {
|
||||
observeForValidation()
|
||||
}
|
||||
|
||||
public func startDiscovery() {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, changes in
|
||||
guard let self else { return }
|
||||
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
|
||||
}
|
||||
|
||||
browser.stateUpdateHandler = { state in
|
||||
if case .failed(let error) = state {
|
||||
print("Discovery error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
private func observeForValidation() {
|
||||
withObservationTracking {
|
||||
_ = serverURL
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
setNeedsValidation()
|
||||
observeForValidation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setNeedsValidation() {
|
||||
self.validationURL = self.serverURL
|
||||
self.validationTimer?.invalidate()
|
||||
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
self?.validateSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSettings() {
|
||||
guard !validationURL.isEmpty else {
|
||||
validationState = .empty
|
||||
return
|
||||
}
|
||||
|
||||
self.validationState = .validating
|
||||
|
||||
Task {
|
||||
do {
|
||||
let url = try URL(string: validationURL).try_unwrap()
|
||||
let api = API(baseURL: url)
|
||||
_ = try await api.fetchNowPlayingInfo()
|
||||
|
||||
self.validationState = .valid
|
||||
|
||||
if validationURL != serverURL {
|
||||
self.serverURL = self.validationURL
|
||||
}
|
||||
} catch {
|
||||
print("Validation failed: \(error)")
|
||||
|
||||
if !validationURL.hasSuffix("/api") {
|
||||
// Try adding /api and validating again.
|
||||
self.validationURL = serverURL.appending("/api")
|
||||
validateSettings()
|
||||
} else {
|
||||
self.validationState = .notValid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum ValidationState
|
||||
{
|
||||
case empty
|
||||
case validating
|
||||
case notValid
|
||||
case valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String {
|
||||
serviceName.queueCubeServiceName
|
||||
}
|
||||
|
||||
var id: String { serviceName }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
self.endpoint = result.endpoint
|
||||
|
||||
switch result.endpoint {
|
||||
case .service(name: let name, type: _, domain: _, interface: _):
|
||||
self.serviceName = name
|
||||
default:
|
||||
self.serviceName = "(Unknown)"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func resolve() async throws -> Server {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .preparing: break
|
||||
case .ready:
|
||||
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
|
||||
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
|
||||
let address = switch host {
|
||||
case .name(let string, _): string
|
||||
case .ipv4(let iPv4Address): iPv4Address.debugDescription
|
||||
case .ipv6(let iPv6Address): iPv6Address.debugDescription
|
||||
default: "unknown"
|
||||
}
|
||||
|
||||
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
|
||||
continuation.resume(returning: server)
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.urlError)
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.endpointIncorrect)
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
case .cancelled:
|
||||
// expected
|
||||
break
|
||||
case .failed(let error):
|
||||
continuation.resume(throwing: error)
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case cancelledConnection
|
||||
case endpointIncorrect
|
||||
case urlError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// GeneralSettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettingsView: View
|
||||
{
|
||||
var body: some View {
|
||||
Text("Nothing here yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// ServerListSettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ServerListSettingsView: View
|
||||
{
|
||||
@State var model = ViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if model.configuredServers.isEmpty {
|
||||
contentPlaceholderView(title: .noServersConfigured, systemImage: "server.rack") {
|
||||
Button {
|
||||
model.isAddServerPresented = true
|
||||
} label: {
|
||||
Text(.addServer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
List($model.configuredServers, editActions: [.delete]) { server in
|
||||
serverListItem(server.wrappedValue)
|
||||
.tag(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigationTitle(.servers)
|
||||
|
||||
.toolbar {
|
||||
Button {
|
||||
model.isAddServerPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
.sheet(isPresented: $model.isAddServerPresented) {
|
||||
NavigationView {
|
||||
AddServerView(onAddServer: { model.onAddServer(server: $0) })
|
||||
.navigationTitle(.addServer)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .cancellationAction) {
|
||||
Button(.cancel) { model.isAddServerPresented = false }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func serverListItem(_ server: Server) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "hifispeaker.fill")
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(server.displayName)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
|
||||
Text(server.baseURL.absoluteString)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var configuredServers: [Server]
|
||||
var isAddServerPresented = false
|
||||
var selectedItems: [Server.ID] = []
|
||||
|
||||
init() {
|
||||
self.configuredServers = Settings
|
||||
.fromDefaults()
|
||||
.configuredServers
|
||||
|
||||
observeForChanges()
|
||||
}
|
||||
|
||||
func observeForChanges() {
|
||||
withObservationTracking {
|
||||
_ = configuredServers
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
saveToSettings()
|
||||
observeForChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onAddServer(server: Server) {
|
||||
isAddServerPresented = false
|
||||
configuredServers = configuredServers + [ server ]
|
||||
saveToSettings()
|
||||
}
|
||||
|
||||
func saveToSettings() {
|
||||
Settings
|
||||
.fromDefaults()
|
||||
.configuredServers(configuredServers)
|
||||
.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 5/2/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View
|
||||
{
|
||||
let onDone: () -> Void
|
||||
@State private var navigationPath: [SettingsPage]
|
||||
|
||||
init(onDone: @escaping () -> Void) {
|
||||
self.onDone = onDone
|
||||
self.navigationPath = if !Settings.fromDefaults().isConfigured {
|
||||
// Show server settings if not configured.
|
||||
[ .servers ]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
List {
|
||||
NavigationLink(value: SettingsPage.general) {
|
||||
Image(systemName: "gear")
|
||||
Text(.general)
|
||||
}
|
||||
|
||||
NavigationLink(value: SettingsPage.servers) {
|
||||
Image(systemName: "server.rack")
|
||||
Text(.servers)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: SettingsPage.self, destination: { page in
|
||||
Group {
|
||||
switch page {
|
||||
case .general: GeneralSettingsView()
|
||||
case .servers: ServerListSettingsView()
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
})
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum SettingsPage: String, Identifiable
|
||||
{
|
||||
var id: String { rawValue }
|
||||
|
||||
case general
|
||||
case servers
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user