diff --git a/CLAUDE.md b/CLAUDE.md index 6779ac7..01ffb9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,27 @@ QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via M - **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates. - **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow. -- **Settings Management**: Server configuration stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes. +- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery. +- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes. ### Data Flow -1. **Settings**: Server URL stored in UserDefaults, validated asynchronously via API calls -2. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume -3. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern -4. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates +1. **Multiple Server Support**: Array of `Server` objects stored in UserDefaults with selected server tracking +2. **Settings**: Server configurations validated asynchronously via API calls with live connectivity testing +3. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume +4. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern +5. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates + +### Request Builder Pattern + +The API layer uses a fluent builder pattern for HTTP requests: +```swift +try await request() + .path("/nowplaying") + .json() +``` + +This provides type-safe, composable API calls with automatic error handling and connection state management. ### Key Features @@ -58,7 +71,23 @@ The server API includes these endpoints: ## UI Structure +### View Hierarchy +``` +QueueCubeApp +└── ContentView (coordination layer) + └── MainView (tab management) + ├── PlaylistView (with embedded NowPlayingView) + ├── FavoritesView (favorites management) + └── SettingsView (server configuration) + ├── ServerListSettingsView + ├── AddServerView + └── GeneralSettingsView +``` + +### Key Views +- **ContentView**: Main coordinator that manages API instances and global state +- **MainView**: Tab-based navigation container with platform-specific adaptations +- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView - **NowPlayingView**: Playback controls and current track display -- **PlaylistView**: Scrollable list of queued media with reorder/delete actions -- **AddMediaBarView**: Input field for adding new media URLs -- **SettingsView**: Server configuration with live validation \ No newline at end of file +- **AddMediaBarView**: Input field for adding new media URLs to playlist +- **SettingsView**: Multi-server configuration with live validation and service discovery \ No newline at end of file diff --git a/QueueCube/Views/ContentView.swift b/QueueCube/Views/ContentView.swift index 660d606..caf524a 100644 --- a/QueueCube/Views/ContentView.swift +++ b/QueueCube/Views/ContentView.swift @@ -31,10 +31,18 @@ struct ContentView: View let playlistModel = model.playlistModel playlistModel.onSeek = { item in - Task { try await api.skip(item.index) } + Task { + if let index = item.index { + try await api.skip(index) + } + } } playlistModel.onDelete = { item in - Task { try await api.delete(index: item.index) } + Task { + if let index = item.index { + try await api.delete(index: index) + } + } } let addMediaModel = model.addMediaViewModel @@ -59,6 +67,10 @@ struct ContentView: View favoritesModel.onPlay = { item in Task { try await api.add(mediaURL: item.filename) } } + favoritesModel.onDelete = { item in + // TODO: Implement favorites deletion when API supports it + print("Delete favorite: \(item.title)") + } } } @@ -104,11 +116,11 @@ extension ContentView if what.contains(.playlist) { let playlist = try await api.fetchPlaylist() model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in - PlaylistItem( - index: idx, + MediaListItem( id: String(mediaItem.id), title: mediaItem.title ?? mediaItem.filename ?? "", filename: mediaItem.filename ?? "", + index: idx, isCurrent: mediaItem.current ?? false ) } @@ -117,7 +129,7 @@ extension ContentView if what.contains(.favorites) { let favorites = try await api.fetchFavorites() model.favoritesModel.items = favorites.map { mediaItem in - FavoriteItem( + MediaListItem( id: String(mediaItem.id), title: mediaItem.title ?? mediaItem.filename ?? "", filename: mediaItem.filename ?? "" diff --git a/QueueCube/Views/MainView.swift b/QueueCube/Views/MainView.swift index d8147e8..4e19272 100644 --- a/QueueCube/Views/MainView.swift +++ b/QueueCube/Views/MainView.swift @@ -10,13 +10,13 @@ import SwiftUI @Observable class MainViewModel { - var selectedServer: Server? = nil + var selectedServer: Server? { Settings.fromDefaults().selectedServer } var connectionError: Error? = nil var selectedTab: Tab = .playlist - var playlistModel = PlaylistViewModel() - var favoritesModel = FavoritesViewModel() + var playlistModel = MediaListViewModel(mode: .playlist) + var favoritesModel = MediaListViewModel(mode: .favorites) var nowPlayingViewModel = NowPlayingViewModel() var addMediaViewModel = AddMediaBarViewModel() @@ -57,11 +57,11 @@ struct MainView: View TabView(selection: $model.selectedTab) { Tab(.playlist, systemImage: "list.bullet", value: .playlist) { - PlaylistView(model: model.playlistModel) + MediaListView(model: model.playlistModel) } Tab(.favorites, systemImage: "heart.fill", value: .favorites) { - FavoritesView(model: model.favoritesModel) + MediaListView(model: model.favoritesModel) } Tab(.settings, systemImage: "gear", value: .settings) { diff --git a/QueueCube/Views/PlaylistView.swift b/QueueCube/Views/PlaylistView.swift index 51f757e..afffc94 100644 --- a/QueueCube/Views/PlaylistView.swift +++ b/QueueCube/Views/PlaylistView.swift @@ -7,87 +7,86 @@ import SwiftUI -struct PlaylistItem: Identifiable +struct MediaListItem: Identifiable { - let index: Int let id: String let title: String let filename: String + let index: Int? let isCurrent: Bool + + 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 + } } -struct FavoriteItem: Identifiable -{ - let id: String - let title: String - let filename: String +enum MediaListMode { + case playlist + case favorites } @Observable -class PlaylistViewModel +class MediaListViewModel { + let mode: MediaListMode var isPlaying: Bool = false - var items: [PlaylistItem] = [] + var items: [MediaListItem] = [] - var onSeek: (PlaylistItem) -> Void = { _ in } - var onDelete: (PlaylistItem) -> Void = { _ in } + var onSeek: (MediaListItem) -> Void = { _ in } + var onPlay: (MediaListItem) -> Void = { _ in } + var onDelete: (MediaListItem) -> Void = { _ in } + + init(mode: MediaListMode) { + self.mode = mode + } } -@Observable -class FavoritesViewModel +struct MediaListView: View { - var items: [FavoriteItem] = [] - - var onPlay: (FavoriteItem) -> Void = { _ in } -} - -struct PlaylistView: View -{ - var model: PlaylistViewModel + var model: MediaListViewModel var body: some View { List(model.items) { item in - PlaylistItemCell( + MediaItemCell( title: item.title, subtitle: item.filename, - state: item.isCurrent ? (model.isPlaying ? PlaylistItemCell.State.playing : PlaylistItemCell.State.paused) - : .queued, - onLeadingIconClick: { model.onSeek(item) }, - onDeleteButtonClick: { model.onDelete(item) }, + mode: model.mode, + state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued, + onLeadingIconClick: { + switch model.mode { + case .playlist: + model.onSeek(item) + case .favorites: + model.onPlay(item) + } + }, + onDeleteButtonClick: { model.onDelete(item) } ) } } } -struct FavoritesView: View -{ - var model: FavoritesViewModel - - var body: some View { - List(model.items) { item in - FavoriteItemCell( - title: item.title, - subtitle: item.filename, - onPlayButtonClick: { model.onPlay(item) } - ) - } - } -} -struct PlaylistItemCell: View +struct MediaItemCell: View { let title: String let subtitle: String + let mode: MediaListMode let state: State let onLeadingIconClick: () -> Void let onDeleteButtonClick: () -> Void var body: some View { - let icon: String = switch state { - case .queued: "play.fill" - case .playing: "speaker.wave.3.fill" - case .paused: "speaker.fill" + let icon: String = switch (mode, state) { + case (.playlist, .queued): "play.fill" + case (.playlist, .playing): "speaker.wave.3.fill" + case (.playlist, .paused): "speaker.fill" + case (.favorites, _): "play.fill" } HStack { @@ -108,6 +107,7 @@ struct PlaylistItemCell: View Spacer() + // Show delete button for both playlist and favorites HStack { Button(action: onDeleteButtonClick) { Image(systemName: "xmark") @@ -115,7 +115,7 @@ struct PlaylistItemCell: View } } } - .listRowBackground(state != .queued ? Color.white.opacity(0.15) : nil) + .listRowBackground((mode == .playlist && state != .queued) ? Color.white.opacity(0.15) : nil) .padding([.top, .bottom], 8.0) } @@ -128,33 +128,3 @@ struct PlaylistItemCell: View } } -struct FavoriteItemCell: View -{ - let title: String - let subtitle: String - let onPlayButtonClick: () -> Void - - var body: some View { - HStack { - Button(action: onPlayButtonClick) { - Image(systemName: "play.fill") - } - .buttonStyle(BorderlessButtonStyle()) - .tint(Color.primary) - .frame(width: 15.0) - - VStack(alignment: .leading) { - Text(title) - .font(.body.bold()) - .lineLimit(1) - - Text(subtitle) - .foregroundColor(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding([.top, .bottom], 8.0) - } -}