Version 1.5: Groups
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user