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.
|
- **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.
|
- **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
|
### Data Flow
|
||||||
|
|
||||||
1. **Settings**: Server URL stored in UserDefaults, validated asynchronously via API calls
|
1. **Multiple Server Support**: Array of `Server` objects stored in UserDefaults with selected server tracking
|
||||||
2. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume
|
2. **Settings**: Server configurations validated asynchronously via API calls with live connectivity testing
|
||||||
3. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern
|
3. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume
|
||||||
4. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates
|
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
|
### Key Features
|
||||||
|
|
||||||
@@ -58,7 +71,23 @@ The server API includes these endpoints:
|
|||||||
|
|
||||||
## UI Structure
|
## 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
|
- **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 to playlist
|
||||||
- **AddMediaBarView**: Input field for adding new media URLs
|
- **SettingsView**: Multi-server configuration with live validation and service discovery
|
||||||
- **SettingsView**: Server configuration with live validation
|
|
||||||
@@ -31,10 +31,18 @@ struct ContentView: View
|
|||||||
|
|
||||||
let playlistModel = model.playlistModel
|
let playlistModel = model.playlistModel
|
||||||
playlistModel.onSeek = { item in
|
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
|
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
|
let addMediaModel = model.addMediaViewModel
|
||||||
@@ -59,6 +67,10 @@ struct ContentView: View
|
|||||||
favoritesModel.onPlay = { item in
|
favoritesModel.onPlay = { item in
|
||||||
Task { try await api.add(mediaURL: item.filename) }
|
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) {
|
if what.contains(.playlist) {
|
||||||
let playlist = try await api.fetchPlaylist()
|
let playlist = try await api.fetchPlaylist()
|
||||||
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
||||||
PlaylistItem(
|
MediaListItem(
|
||||||
index: idx,
|
|
||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.title ?? mediaItem.filename ?? "<null>",
|
title: mediaItem.title ?? mediaItem.filename ?? "<null>",
|
||||||
filename: mediaItem.filename ?? "<null>",
|
filename: mediaItem.filename ?? "<null>",
|
||||||
|
index: idx,
|
||||||
isCurrent: mediaItem.current ?? false
|
isCurrent: mediaItem.current ?? false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -117,7 +129,7 @@ extension ContentView
|
|||||||
if what.contains(.favorites) {
|
if what.contains(.favorites) {
|
||||||
let favorites = try await api.fetchFavorites()
|
let favorites = try await api.fetchFavorites()
|
||||||
model.favoritesModel.items = favorites.map { mediaItem in
|
model.favoritesModel.items = favorites.map { mediaItem in
|
||||||
FavoriteItem(
|
MediaListItem(
|
||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.title ?? mediaItem.filename ?? "<null>",
|
title: mediaItem.title ?? mediaItem.filename ?? "<null>",
|
||||||
filename: mediaItem.filename ?? "<null>"
|
filename: mediaItem.filename ?? "<null>"
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import SwiftUI
|
|||||||
@Observable
|
@Observable
|
||||||
class MainViewModel
|
class MainViewModel
|
||||||
{
|
{
|
||||||
var selectedServer: Server? = nil
|
var selectedServer: Server? { Settings.fromDefaults().selectedServer }
|
||||||
|
|
||||||
var connectionError: Error? = nil
|
var connectionError: Error? = nil
|
||||||
var selectedTab: Tab = .playlist
|
var selectedTab: Tab = .playlist
|
||||||
|
|
||||||
var playlistModel = PlaylistViewModel()
|
var playlistModel = MediaListViewModel(mode: .playlist)
|
||||||
var favoritesModel = FavoritesViewModel()
|
var favoritesModel = MediaListViewModel(mode: .favorites)
|
||||||
var nowPlayingViewModel = NowPlayingViewModel()
|
var nowPlayingViewModel = NowPlayingViewModel()
|
||||||
var addMediaViewModel = AddMediaBarViewModel()
|
var addMediaViewModel = AddMediaBarViewModel()
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ struct MainView: View
|
|||||||
|
|
||||||
TabView(selection: $model.selectedTab) {
|
TabView(selection: $model.selectedTab) {
|
||||||
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
||||||
PlaylistView(model: model.playlistModel)
|
MediaListView(model: model.playlistModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
||||||
FavoritesView(model: model.favoritesModel)
|
MediaListView(model: model.favoritesModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
Tab(.settings, systemImage: "gear", value: .settings) {
|
Tab(.settings, systemImage: "gear", value: .settings) {
|
||||||
|
|||||||
@@ -7,87 +7,86 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PlaylistItem: Identifiable
|
struct MediaListItem: Identifiable
|
||||||
{
|
{
|
||||||
let index: Int
|
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let filename: String
|
let filename: String
|
||||||
|
let index: Int?
|
||||||
let isCurrent: Bool
|
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
|
enum MediaListMode {
|
||||||
{
|
case playlist
|
||||||
let id: String
|
case favorites
|
||||||
let title: String
|
|
||||||
let filename: String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class PlaylistViewModel
|
class MediaListViewModel
|
||||||
{
|
{
|
||||||
|
let mode: MediaListMode
|
||||||
var isPlaying: Bool = false
|
var isPlaying: Bool = false
|
||||||
var items: [PlaylistItem] = []
|
var items: [MediaListItem] = []
|
||||||
|
|
||||||
var onSeek: (PlaylistItem) -> Void = { _ in }
|
var onSeek: (MediaListItem) -> Void = { _ in }
|
||||||
var onDelete: (PlaylistItem) -> Void = { _ in }
|
var onPlay: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onDelete: (MediaListItem) -> Void = { _ in }
|
||||||
|
|
||||||
|
init(mode: MediaListMode) {
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
struct MediaListView: View
|
||||||
class FavoritesViewModel
|
|
||||||
{
|
{
|
||||||
var items: [FavoriteItem] = []
|
var model: MediaListViewModel
|
||||||
|
|
||||||
var onPlay: (FavoriteItem) -> Void = { _ in }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PlaylistView: View
|
|
||||||
{
|
|
||||||
var model: PlaylistViewModel
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(model.items) { item in
|
List(model.items) { item in
|
||||||
PlaylistItemCell(
|
MediaItemCell(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.filename,
|
subtitle: item.filename,
|
||||||
state: item.isCurrent ? (model.isPlaying ? PlaylistItemCell.State.playing : PlaylistItemCell.State.paused)
|
mode: model.mode,
|
||||||
: .queued,
|
state: item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued,
|
||||||
onLeadingIconClick: { model.onSeek(item) },
|
onLeadingIconClick: {
|
||||||
onDeleteButtonClick: { model.onDelete(item) },
|
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 title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
|
let mode: MediaListMode
|
||||||
let state: State
|
let state: State
|
||||||
|
|
||||||
let onLeadingIconClick: () -> Void
|
let onLeadingIconClick: () -> Void
|
||||||
let onDeleteButtonClick: () -> Void
|
let onDeleteButtonClick: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let icon: String = switch state {
|
let icon: String = switch (mode, state) {
|
||||||
case .queued: "play.fill"
|
case (.playlist, .queued): "play.fill"
|
||||||
case .playing: "speaker.wave.3.fill"
|
case (.playlist, .playing): "speaker.wave.3.fill"
|
||||||
case .paused: "speaker.fill"
|
case (.playlist, .paused): "speaker.fill"
|
||||||
|
case (.favorites, _): "play.fill"
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
@@ -108,6 +107,7 @@ struct PlaylistItemCell: View
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Show delete button for both playlist and favorites
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: onDeleteButtonClick) {
|
Button(action: onDeleteButtonClick) {
|
||||||
Image(systemName: "xmark")
|
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)
|
.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