Unify playlist/favorites views

This commit is contained in:
2025-06-11 12:12:53 -07:00
parent 7e6d449c52
commit 51048678bb
4 changed files with 105 additions and 94 deletions

View File

@@ -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

View File

@@ -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>"

View File

@@ -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) {

View File

@@ -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)
}
}