Version 1.5: Groups

This commit is contained in:
2026-04-16 05:48:51 +02:00
parent ae706bc8bc
commit e9462f6d91
8 changed files with 959 additions and 203 deletions
+287 -89
View File
@@ -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)?"
}
}