Version one on the App Store
This commit is contained in:
@@ -9,27 +9,27 @@ import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Players", systemImage: "speaker.wave.2.fill") {
|
||||
PlayerListView()
|
||||
}
|
||||
|
||||
Tab("Library", systemImage: "music.note.list") {
|
||||
LibraryView()
|
||||
}
|
||||
|
||||
|
||||
Tab("Players", systemImage: "speaker.wave.2.fill") {
|
||||
PlayerListView()
|
||||
}
|
||||
|
||||
Tab("Settings", systemImage: "gear") {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Start listening to player events when main view appears
|
||||
// Start listening to player events and load players when main view appears
|
||||
service.playerManager.startListening()
|
||||
try? await service.playerManager.loadPlayers()
|
||||
}
|
||||
.onDisappear {
|
||||
// Stop listening when view disappears
|
||||
service.playerManager.stopListening()
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,31 @@ struct PlayerListView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var players: [MAPlayer] {
|
||||
Array(service.playerManager.players.values).sorted { $0.name < $1.name }
|
||||
@State private var nowPlayingPlayer: MAPlayer?
|
||||
|
||||
private var allPlayers: [MAPlayer] {
|
||||
Array(service.playerManager.players.values)
|
||||
.filter { $0.available }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
|
||||
/// IDs of all players that are sync members (not the leader)
|
||||
private var syncedMemberIds: Set<String> {
|
||||
Set(allPlayers.flatMap { $0.groupChilds })
|
||||
}
|
||||
|
||||
/// Players that are sync group leaders (shown as group cards at the top)
|
||||
private var groupLeaders: [MAPlayer] {
|
||||
allPlayers.filter { $0.isGroupLeader }
|
||||
}
|
||||
|
||||
/// Players that are neither a group leader nor a member of any group
|
||||
private var soloPlayers: [MAPlayer] {
|
||||
allPlayers.filter { !$0.isGroupLeader && !syncedMemberIds.contains($0.playerId) }
|
||||
}
|
||||
|
||||
private var hasContent: Bool { !allPlayers.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
@@ -57,20 +77,39 @@ struct PlayerListView: View {
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(errorMessage)
|
||||
)
|
||||
} else if players.isEmpty {
|
||||
} else if !hasContent {
|
||||
ContentUnavailableView(
|
||||
"No Players Found",
|
||||
systemImage: "speaker.slash",
|
||||
description: Text("Make sure your Music Assistant server has configured players")
|
||||
)
|
||||
} else {
|
||||
List(players) { player in
|
||||
NavigationLink(value: player.playerId) {
|
||||
PlayerRow(player: player)
|
||||
ScrollView {
|
||||
// VStack (not Lazy) ensures all drop targets are always rendered
|
||||
VStack(spacing: 12) {
|
||||
// Groups shown at the top
|
||||
ForEach(groupLeaders) { leader in
|
||||
let memberNames = leader.groupChilds
|
||||
.compactMap { service.playerManager.players[$0]?.name }
|
||||
PlayerGroupRow(
|
||||
leader: leader,
|
||||
memberNames: memberNames,
|
||||
onTap: { nowPlayingPlayer = leader },
|
||||
onDissolve: {
|
||||
Task { try? await service.playerManager.unsyncPlayer(playerId: leader.playerId) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Solo players — drag handle initiates drag, card accepts drops
|
||||
ForEach(soloPlayers) { player in
|
||||
PlayerRow(player: player) {
|
||||
nowPlayingPlayer = player
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: String.self) { playerId in
|
||||
PlayerView(playerId: playerId)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,139 +117,317 @@ struct PlayerListView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task {
|
||||
await loadPlayers()
|
||||
}
|
||||
Task { await loadPlayers() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
.withMANavigation()
|
||||
.task {
|
||||
await loadPlayers()
|
||||
}
|
||||
.sheet(item: $nowPlayingPlayer) { selectedPlayer in
|
||||
PlayerNowPlayingView(playerId: selectedPlayer.playerId)
|
||||
.environment(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadPlayers() async {
|
||||
print("🔵 PlayerListView: Starting to load players...")
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
print("🔵 PlayerListView: Calling playerManager.loadPlayers()")
|
||||
try await service.playerManager.loadPlayers()
|
||||
print("✅ PlayerListView: Successfully loaded \(players.count) players")
|
||||
} catch {
|
||||
print("❌ PlayerListView: Failed to load players: \(error)")
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Group Row
|
||||
|
||||
struct PlayerGroupRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let leader: MAPlayer
|
||||
let memberNames: [String]
|
||||
let onTap: () -> Void
|
||||
let onDissolve: () -> Void
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[leader.playerId]?.currentItem
|
||||
}
|
||||
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
|
||||
|
||||
private var groupName: String {
|
||||
([leader.name] + memberNames).joined(separator: " + ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "speaker.2.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
if leader.state == .playing {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(groupName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let item = currentItem {
|
||||
Text(item.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let artists = mediaItem?.artists, !artists.isEmpty {
|
||||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Text(leader.state == .off ? "Powered Off" : "No Track Playing")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Play/pause
|
||||
Button {
|
||||
Task {
|
||||
if leader.state == .playing {
|
||||
try? await service.playerManager.pause(playerId: leader.playerId)
|
||||
} else {
|
||||
try? await service.playerManager.play(playerId: leader.playerId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: leader.state == .playing ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(leader.state == .playing ? .green : .secondary)
|
||||
.symbolEffect(.bounce, value: leader.state == .playing)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Dissolve group button
|
||||
Button(action: onDissolve) {
|
||||
Image(systemName: "xmark.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.red.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
ZStack {
|
||||
CachedAsyncImage(url: service.imageProxyURL(
|
||||
path: mediaItem?.imageUrl,
|
||||
provider: mediaItem?.imageProvider,
|
||||
size: 256
|
||||
)) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.clear
|
||||
}
|
||||
.blur(radius: 20)
|
||||
.scaleEffect(1.1)
|
||||
.clipped()
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Row
|
||||
|
||||
struct PlayerRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let player: MAPlayer
|
||||
|
||||
let onTap: () -> Void
|
||||
@State private var isDropTarget = false
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[player.playerId]?.currentItem
|
||||
}
|
||||
|
||||
private var mediaItem: MAMediaItem? {
|
||||
currentItem?.mediaItem
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Album Art Thumbnail
|
||||
if let item = player.currentItem,
|
||||
let mediaItem = item.mediaItem,
|
||||
let imageUrl = mediaItem.imageUrl {
|
||||
let coverURL = service.imageProxyURL(path: imageUrl, size: 64)
|
||||
|
||||
CachedAsyncImage(url: coverURL) { image in
|
||||
// Drag handle — long-press this icon then drag onto another player to group them.
|
||||
// Keeping it on a small dedicated view avoids conflicts with the ScrollView gesture.
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
.draggable(player.playerId)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
if player.state == .playing {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(player.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let item = currentItem {
|
||||
Text(item.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
if let artists = mediaItem?.artists, !artists.isEmpty {
|
||||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Text(player.state == .off ? "Powered Off" : "No Track Playing")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task {
|
||||
if player.state == .playing {
|
||||
try? await service.playerManager.pause(playerId: player.playerId)
|
||||
} else {
|
||||
try? await service.playerManager.play(playerId: player.playerId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(player.state == .playing ? .green : .secondary)
|
||||
.symbolEffect(.bounce, value: player.state == .playing)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
ZStack {
|
||||
CachedAsyncImage(url: service.imageProxyURL(
|
||||
path: mediaItem?.imageUrl,
|
||||
provider: mediaItem?.imageProvider,
|
||||
size: 256
|
||||
)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
}
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(width: 48, height: 48)
|
||||
.overlay {
|
||||
Image(systemName: "music.note")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// Player Info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(player.name)
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: stateIcon)
|
||||
.foregroundStyle(stateColor)
|
||||
.font(.caption)
|
||||
Text(player.state.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let item = player.currentItem {
|
||||
Text("• \(item.name)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Volume Indicator
|
||||
if player.available {
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(player.volume)%")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Color.clear
|
||||
}
|
||||
.blur(radius: 20)
|
||||
.scaleEffect(1.1)
|
||||
.clipped()
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch player.state {
|
||||
case .playing: return "play.circle.fill"
|
||||
case .paused: return "pause.circle.fill"
|
||||
case .idle: return "stop.circle"
|
||||
case .off: return "power.circle"
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay {
|
||||
if isDropTarget {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(Color.accentColor, lineWidth: 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.accentColor.opacity(0.08))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var stateColor: Color {
|
||||
switch player.state {
|
||||
case .playing: return .green
|
||||
case .paused: return .orange
|
||||
case .idle: return .gray
|
||||
case .off: return .red
|
||||
.onTapGesture { onTap() }
|
||||
// The full card is the drop target — drop a player from another card's handle here
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let draggedId = items.first, draggedId != player.playerId else { return false }
|
||||
Task { try? await service.playerManager.syncPlayer(playerId: draggedId, targetPlayerId: player.playerId) }
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
isDropTarget = targeted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removed - Now using dedicated PlayerView.swift file
|
||||
|
||||
// Removed - Now using dedicated LibraryView.swift file
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@Environment(\.themeManager) private var themeManager
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Appearance Section
|
||||
Section {
|
||||
ForEach(AppColorScheme.allCases) { scheme in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
themeManager.colorScheme = scheme
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: scheme.icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(themeManager.colorScheme == scheme ? .blue : .secondary)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(scheme.displayName)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(scheme.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if themeManager.colorScheme == scheme {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
} footer: {
|
||||
Text("Choose how the app looks. System follows your device settings.")
|
||||
}
|
||||
|
||||
// Connection Section
|
||||
Section {
|
||||
if let serverURL = service.authManager.serverURL {
|
||||
LabeledContent("Server", value: serverURL.absoluteString)
|
||||
@@ -224,8 +441,11 @@ struct SettingsView: View {
|
||||
Text(service.isConnected ? "Connected" : "Disconnected")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Connection")
|
||||
}
|
||||
|
||||
// Actions Section
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
service.disconnect()
|
||||
|
||||
Reference in New Issue
Block a user