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
+53 -35
View File
@@ -11,6 +11,12 @@ struct PlayerNowPlayingView: View {
@Environment(MAService.self) private var service
@Environment(\.dismiss) private var dismiss
let playerId: String
/// When this player is a sync member, the leader's ID is used for
/// queue data, album art, track info, and transport controls.
var leaderPlayerId: String? = nil
/// The ID to use for everything except volume.
private var effectivePlayerId: String { leaderPlayerId ?? playerId }
@State private var localVolume: Double = 0
@State private var isVolumeEditing = false
@@ -30,12 +36,18 @@ struct PlayerNowPlayingView: View {
@State private var showClearConfirm = false
// Auto-tracks live updates via @Observable
/// Volume and mute state come from this player (the member itself).
private var player: MAPlayer? {
service.playerManager.players[playerId]
}
/// Queue, track info, and transport state come from the effective player (leader if synced).
private var effectivePlayer: MAPlayer? {
service.playerManager.players[effectivePlayerId]
}
private var playerQueue: MAPlayerQueue? {
service.playerManager.playerQueues[playerId]
service.playerManager.playerQueues[effectivePlayerId]
}
private var currentItem: MAQueueItem? {
@@ -50,25 +62,25 @@ struct PlayerNowPlayingView: View {
Double(currentItem?.duration ?? 0)
}
// Queue computed properties
// Queue computed properties all use effectivePlayerId (leader when synced)
private var queueItems: [MAQueueItem] {
service.playerManager.queues[playerId] ?? []
service.playerManager.queues[effectivePlayerId] ?? []
}
private var currentQueueIndex: Int? {
service.playerManager.playerQueues[playerId]?.currentIndex
service.playerManager.playerQueues[effectivePlayerId]?.currentIndex
}
private var currentItemId: String? {
service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId
service.playerManager.playerQueues[effectivePlayerId]?.currentItem?.queueItemId
}
private var shuffleEnabled: Bool {
service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false
service.playerManager.playerQueues[effectivePlayerId]?.shuffleEnabled ?? false
}
private var repeatMode: RepeatMode {
service.playerManager.playerQueues[playerId]?.repeatMode ?? .off
service.playerManager.playerQueues[effectivePlayerId]?.repeatMode ?? .off
}
var body: some View {
@@ -148,9 +160,9 @@ struct PlayerNowPlayingView: View {
.onChange(of: playerQueue?.elapsedTime) {
if !isProgressEditing { syncElapsedTime() }
}
.onChange(of: player?.state) {
.onChange(of: effectivePlayer?.state) {
syncElapsedTime()
if player?.state == .playing {
if effectivePlayer?.state == .playing {
startProgressTimer()
} else {
progressTimer?.invalidate()
@@ -159,12 +171,12 @@ struct PlayerNowPlayingView: View {
}
.task {
isQueueLoading = true
try? await service.playerManager.loadQueue(playerId: playerId)
try? await service.playerManager.loadQueue(playerId: effectivePlayerId)
isQueueLoading = false
}
.confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) {
Button("Clear Queue", role: .destructive) {
Task { try? await service.playerManager.clearQueue(playerId: playerId) }
Task { try? await service.playerManager.clearQueue(playerId: effectivePlayerId) }
}
Button("Cancel", role: .cancel) { }
}
@@ -196,10 +208,10 @@ struct PlayerNowPlayingView: View {
Spacer()
VStack(spacing: 2) {
Text("Now Playing")
Text(leaderPlayerId != nil ? player?.name ?? "Now Playing" : "Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.name ?? "")
Text(effectivePlayer?.name ?? "")
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
@@ -291,10 +303,10 @@ struct PlayerNowPlayingView: View {
private var controlsView: some View {
VStack(spacing: 16) {
// Transport controls
if let player {
if let ep = effectivePlayer {
HStack(spacing: 48) {
Button {
Task { try? await service.playerManager.previousTrack(playerId: playerId) }
Task { try? await service.playerManager.previousTrack(playerId: effectivePlayerId) }
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 30))
@@ -303,21 +315,21 @@ struct PlayerNowPlayingView: View {
Button {
Task {
if player.state == .playing {
try? await service.playerManager.pause(playerId: playerId)
if ep.state == .playing {
try? await service.playerManager.pause(playerId: effectivePlayerId)
} else {
try? await service.playerManager.play(playerId: playerId)
try? await service.playerManager.play(playerId: effectivePlayerId)
}
}
} label: {
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
Image(systemName: ep.state == .playing ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
.symbolEffect(.bounce, value: player.state == .playing)
.symbolEffect(.bounce, value: ep.state == .playing)
}
Button {
Task { try? await service.playerManager.nextTrack(playerId: playerId) }
Task { try? await service.playerManager.nextTrack(playerId: effectivePlayerId) }
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 30))
@@ -369,10 +381,7 @@ struct PlayerNowPlayingView: View {
.onEnded { value in
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(localVolume)
)
try? await setVolumeForPlayer(level: Int(localVolume))
}
volumeSettleTask?.cancel()
volumeSettleTask = Task { @MainActor in
@@ -436,7 +445,7 @@ struct PlayerNowPlayingView: View {
displayedElapsed = seekTo
Task {
do {
try await service.playerManager.seek(playerId: playerId, position: seekTo)
try await service.playerManager.seek(playerId: effectivePlayerId, position: seekTo)
} catch {
print("❌ Seek failed: \(error)")
}
@@ -523,7 +532,7 @@ struct PlayerNowPlayingView: View {
.onTapGesture {
Task {
try? await service.playerManager.playIndex(
playerId: playerId,
playerId: effectivePlayerId,
index: index
)
}
@@ -541,7 +550,7 @@ struct PlayerNowPlayingView: View {
HStack(spacing: 0) {
// Shuffle
Button {
Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) }
Task { try? await service.playerManager.setShuffle(playerId: effectivePlayerId, enabled: !shuffleEnabled) }
} label: {
VStack(spacing: 3) {
Image(systemName: "shuffle")
@@ -563,7 +572,7 @@ struct PlayerNowPlayingView: View {
case .all: next = .one
case .one: next = .off
}
Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) }
Task { try? await service.playerManager.setRepeatMode(playerId: effectivePlayerId, mode: next) }
} label: {
VStack(spacing: 3) {
Image(systemName: repeatMode == .one ? "repeat.1" : "repeat")
@@ -606,7 +615,7 @@ struct PlayerNowPlayingView: View {
return
}
if player?.state == .playing,
if effectivePlayer?.state == .playing,
let lastUpdated = queue.elapsedTimeLastUpdated {
let serverNow = Date().timeIntervalSince1970
let delta = serverNow - lastUpdated
@@ -618,10 +627,10 @@ struct PlayerNowPlayingView: View {
private func startProgressTimer() {
progressTimer?.invalidate()
guard player?.state == .playing else { return }
guard effectivePlayer?.state == .playing else { return }
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
Task { @MainActor in
guard player?.state == .playing, !isProgressEditing else { return }
guard effectivePlayer?.state == .playing, !isProgressEditing else { return }
displayedElapsed += 0.5
if trackDuration > 0 {
displayedElapsed = min(displayedElapsed, trackDuration)
@@ -639,11 +648,20 @@ struct PlayerNowPlayingView: View {
// MARK: - Volume Helpers
/// Routes to group_volume for group leaders, individual volume otherwise.
private func setVolumeForPlayer(level: Int) async throws {
if player?.isGroupLeader == true {
try await service.playerManager.setGroupVolume(playerId: playerId, level: level)
} else {
try await service.playerManager.setVolume(playerId: playerId, level: level)
}
}
private func adjustVolume(by delta: Int) {
let newVolume = max(0, min(100, Int(localVolume) + delta))
localVolume = Double(newVolume)
if isMuted && delta > 0 { isMuted = false }
Task { try? await service.playerManager.setVolume(playerId: playerId, level: newVolume) }
Task { try? await setVolumeForPlayer(level: newVolume) }
}
private func handleMute() {
@@ -651,12 +669,12 @@ struct PlayerNowPlayingView: View {
let restore = preMuteVolume > 0 ? preMuteVolume : 50
localVolume = restore
isMuted = false
Task { try? await service.playerManager.setVolume(playerId: playerId, level: Int(restore)) }
Task { try? await setVolumeForPlayer(level: Int(restore)) }
} else {
preMuteVolume = localVolume
localVolume = 0
isMuted = true
Task { try? await service.playerManager.setVolume(playerId: playerId, level: 0) }
Task { try? await setVolumeForPlayer(level: 0) }
}
}
}