Unify playlist/favorites views
This commit is contained in:
45
CLAUDE.md
45
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
|
||||
- **AddMediaBarView**: Input field for adding new media URLs to playlist
|
||||
- **SettingsView**: Multi-server configuration with live validation and service discovery
|
||||
@@ -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 ?? "<null>",
|
||||
filename: mediaItem.filename ?? "<null>",
|
||||
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 ?? "<null>",
|
||||
filename: mediaItem.filename ?? "<null>"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user