Version 1.5: Groups
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import OSLog
|
||||
|
||||
private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "MobileMA", category: "PlayerSync")
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@@ -51,11 +54,18 @@ struct MainTabView: View {
|
||||
|
||||
// MARK: - Placeholder Views (to be implemented in Phase 2+)
|
||||
|
||||
/// Carries which player to show in Now Playing and, for sync members, who the leader is.
|
||||
private struct NowPlayingTarget: Identifiable {
|
||||
let playerId: String
|
||||
let leaderPlayerId: String?
|
||||
var id: String { playerId }
|
||||
}
|
||||
|
||||
struct PlayerListView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var nowPlayingPlayer: MAPlayer?
|
||||
@State private var nowPlayingTarget: NowPlayingTarget?
|
||||
|
||||
private var allPlayers: [MAPlayer] {
|
||||
Array(service.playerManager.players.values)
|
||||
@@ -103,14 +113,18 @@ struct PlayerListView: View {
|
||||
VStack(spacing: 12) {
|
||||
// Groups shown at the top
|
||||
ForEach(groupLeaders) { leader in
|
||||
let memberNames = leader.groupChilds
|
||||
.compactMap { service.playerManager.players[$0]?.name }
|
||||
let members = leader.groupChilds.compactMap { service.playerManager.players[$0] }
|
||||
PlayerGroupRow(
|
||||
leader: leader,
|
||||
memberNames: memberNames,
|
||||
onTap: { nowPlayingPlayer = leader },
|
||||
members: members,
|
||||
onTap: { player, leaderPid in
|
||||
nowPlayingTarget = NowPlayingTarget(playerId: player.playerId, leaderPlayerId: leaderPid)
|
||||
},
|
||||
onDissolve: {
|
||||
Task { try? await service.playerManager.unsyncPlayer(playerId: leader.playerId) }
|
||||
},
|
||||
onRemoveMember: { member in
|
||||
Task { try? await service.playerManager.unsyncPlayer(playerId: member.playerId) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -118,7 +132,7 @@ struct PlayerListView: View {
|
||||
// Solo players — drag handle initiates drag, card accepts drops
|
||||
ForEach(soloPlayers) { player in
|
||||
PlayerRow(player: player) {
|
||||
nowPlayingPlayer = player
|
||||
nowPlayingTarget = NowPlayingTarget(playerId: player.playerId, leaderPlayerId: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +141,7 @@ struct PlayerListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Players")
|
||||
.navigationTitle("Players & Groups")
|
||||
.withMANavigation()
|
||||
.task {
|
||||
await loadPlayers()
|
||||
@@ -135,8 +149,8 @@ struct PlayerListView: View {
|
||||
.refreshable {
|
||||
await loadPlayers()
|
||||
}
|
||||
.sheet(item: $nowPlayingPlayer) { selectedPlayer in
|
||||
PlayerNowPlayingView(playerId: selectedPlayer.playerId)
|
||||
.sheet(item: $nowPlayingTarget) { target in
|
||||
PlayerNowPlayingView(playerId: target.playerId, leaderPlayerId: target.leaderPlayerId)
|
||||
.environment(service)
|
||||
}
|
||||
}
|
||||
@@ -158,10 +172,17 @@ struct PlayerListView: View {
|
||||
|
||||
struct PlayerGroupRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@Environment(MAToastManager.self) private var toastManager
|
||||
let leader: MAPlayer
|
||||
let memberNames: [String]
|
||||
let onTap: () -> Void
|
||||
let members: [MAPlayer]
|
||||
/// Called with (tappedPlayer, leaderPlayerId). leaderPlayerId is nil when the leader itself is tapped.
|
||||
let onTap: (MAPlayer, String?) -> Void
|
||||
let onDissolve: () -> Void
|
||||
let onRemoveMember: (MAPlayer) -> Void
|
||||
|
||||
@State private var showDissolveConfirm = false
|
||||
@State private var isDropTarget = false
|
||||
@State private var pendingDraggedId: String? = nil
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[leader.playerId]?.currentItem
|
||||
@@ -169,94 +190,215 @@ struct PlayerGroupRow: View {
|
||||
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
|
||||
|
||||
private var groupName: String {
|
||||
([leader.name] + memberNames).joined(separator: " + ")
|
||||
([leader.name] + members.map(\.name)).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")
|
||||
VStack(spacing: 8) {
|
||||
// Leader card
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "speaker.2.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(.blue)
|
||||
if leader.state == .playing {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(groupName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
} 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 { showDissolveConfirm = true } label: {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.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)))
|
||||
}
|
||||
}
|
||||
.onTapGesture { onTap(leader, nil) }
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let draggedId = items.first,
|
||||
draggedId != leader.playerId,
|
||||
!(service.playerManager.players[draggedId]?.isGroupLeader ?? false),
|
||||
!(service.playerManager.players[draggedId]?.isSyncMember ?? false)
|
||||
else { return false }
|
||||
pendingDraggedId = draggedId
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
isDropTarget = targeted
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDraggedId.flatMap { service.playerManager.players[$0]?.name }
|
||||
.map { "Add \"\($0)\" to Group?" } ?? "Add to Group?",
|
||||
isPresented: Binding(get: { pendingDraggedId != nil }, set: { if !$0 { pendingDraggedId = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "Add to Group")) {
|
||||
guard let draggedId = pendingDraggedId else { return }
|
||||
pendingDraggedId = nil
|
||||
Task {
|
||||
do {
|
||||
try await service.playerManager.syncPlayer(playerId: draggedId, targetPlayerId: leader.playerId)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
syncLogger.error("syncPlayer failed: \(msg)")
|
||||
await MainActor.run {
|
||||
toastManager.show(msg, icon: "exclamationmark.triangle", iconColor: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
Button("Cancel", role: .cancel) { pendingDraggedId = nil }
|
||||
}
|
||||
.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
|
||||
.confirmationDialog(
|
||||
String(localized: "Dissolve Group"),
|
||||
isPresented: $showDissolveConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "Dissolve Group"), role: .destructive) {
|
||||
onDissolve()
|
||||
toastManager.show(String(localized: "Group dissolved"), icon: "speaker.slash", iconColor: .orange)
|
||||
}
|
||||
.blur(radius: 20)
|
||||
.scaleEffect(1.1)
|
||||
.clipped()
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
|
||||
// Indented member sub-cards
|
||||
ForEach(members) { member in
|
||||
PlayerMemberRow(
|
||||
member: member,
|
||||
leaderName: leader.name,
|
||||
onTap: { onTap(member, leader.playerId) },
|
||||
onRemove: {
|
||||
onRemoveMember(member)
|
||||
toastManager.show(String(localized: "Player removed"), icon: "minus.circle", iconColor: .orange)
|
||||
}
|
||||
)
|
||||
.padding(.leading, 24)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Member Row
|
||||
|
||||
struct PlayerMemberRow: View {
|
||||
let member: MAPlayer
|
||||
let leaderName: String
|
||||
let onTap: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Vertical connection line
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.3))
|
||||
.frame(width: 2)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "link")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
Text(member.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text("Synced to: \(leaderName)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "minus.circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.orange.opacity(0.8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
.onTapGesture { onTap() }
|
||||
}
|
||||
}
|
||||
@@ -265,9 +407,12 @@ struct PlayerGroupRow: View {
|
||||
|
||||
struct PlayerRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@Environment(MAToastManager.self) private var toastManager
|
||||
let player: MAPlayer
|
||||
let onTap: () -> Void
|
||||
@State private var isDropTarget = false
|
||||
/// Non-nil when a drop landed and we're waiting for the user to confirm grouping.
|
||||
@State private var pendingDraggedId: String? = nil
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[player.playerId]?.currentItem
|
||||
@@ -279,14 +424,11 @@ struct PlayerRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// 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.
|
||||
// Visual drag affordance — the whole card is draggable via .draggable below.
|
||||
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) {
|
||||
@@ -373,14 +515,70 @@ struct PlayerRow: View {
|
||||
}
|
||||
}
|
||||
.onTapGesture { onTap() }
|
||||
// The full card is the drop target — drop a player from another card's handle here
|
||||
// Long-press anywhere on the card to initiate a drag onto another player's card.
|
||||
.draggable(player.playerId)
|
||||
// Guard: no self-drop, no drop onto a sync member, no drop from a group leader.
|
||||
.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) }
|
||||
guard let draggedId = items.first,
|
||||
draggedId != player.playerId,
|
||||
!player.isSyncMember,
|
||||
!(service.playerManager.players[draggedId]?.isGroupLeader ?? false)
|
||||
else { return false }
|
||||
// Show confirmation dialog instead of grouping immediately
|
||||
pendingDraggedId = draggedId
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
isDropTarget = targeted
|
||||
}
|
||||
.confirmationDialog(groupConfirmTitle, isPresented: Binding(
|
||||
get: { pendingDraggedId != nil },
|
||||
set: { if !$0 { pendingDraggedId = nil } }
|
||||
), titleVisibility: .visible) {
|
||||
Button("Create Group") {
|
||||
guard let draggedId = pendingDraggedId else { return }
|
||||
pendingDraggedId = nil
|
||||
let draggedPlayer = service.playerManager.players[draggedId]
|
||||
// The playing player becomes the leader (targetPlayerId).
|
||||
// If neither or both are playing, the target (this card) is the leader.
|
||||
let leaderId: String
|
||||
let followerId: String
|
||||
if draggedPlayer?.state == .playing, player.state != .playing {
|
||||
leaderId = draggedId
|
||||
followerId = player.playerId
|
||||
} else {
|
||||
leaderId = player.playerId
|
||||
followerId = draggedId
|
||||
}
|
||||
syncLogger.debug("Grouping: follower=\(followerId) leader=\(leaderId) draggedState=\(draggedPlayer?.state.rawValue ?? "nil") targetState=\(player.state.rawValue)")
|
||||
Task {
|
||||
do {
|
||||
try await service.playerManager.syncPlayer(playerId: followerId, targetPlayerId: leaderId)
|
||||
} catch {
|
||||
let msg = error.localizedDescription
|
||||
syncLogger.error("syncPlayer failed: \(msg)")
|
||||
await MainActor.run {
|
||||
toastManager.show(msg, icon: "exclamationmark.triangle", iconColor: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingDraggedId = nil
|
||||
}
|
||||
} message: {
|
||||
if let draggedId = pendingDraggedId,
|
||||
let draggedName = service.playerManager.players[draggedId]?.name {
|
||||
Text("Group \"\(player.name)\" with \"\(draggedName)\"?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var groupConfirmTitle: String {
|
||||
guard let draggedId = pendingDraggedId,
|
||||
let draggedName = service.playerManager.players[draggedId]?.name else {
|
||||
return "Create Group?"
|
||||
}
|
||||
return "Group with \(draggedName)?"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user