// // PlayerQueueView.swift // Mobile Music Assistant // // Created by Sven Hanold on 06.04.26. // import SwiftUI struct PlayerQueueView: View { @Environment(MAService.self) private var service let playerId: String @State private var isLoading = false @State private var showClearConfirm = false private var queueItems: [MAQueueItem] { service.playerManager.queues[playerId] ?? [] } private var currentIndex: Int? { service.playerManager.playerQueues[playerId]?.currentIndex } private var currentItemId: String? { service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId } private var shuffleEnabled: Bool { service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false } private var repeatMode: RepeatMode { service.playerManager.playerQueues[playerId]?.repeatMode ?? .off } var body: some View { VStack(spacing: 0) { // Control buttons controlBar .padding(.horizontal, 16) .padding(.vertical, 10) Divider() // Queue list if isLoading && queueItems.isEmpty { Spacer() ProgressView() Spacer() } else if queueItems.isEmpty { Spacer() Text("Queue is empty") .font(.subheadline) .foregroundStyle(.secondary) Spacer() } else { queueList } } .task { isLoading = true try? await service.playerManager.loadQueue(playerId: playerId) isLoading = false } .confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) { Button("Clear Queue", role: .destructive) { Task { try? await service.playerManager.clearQueue(playerId: playerId) } } Button("Cancel", role: .cancel) { } } } // MARK: - Control Bar @ViewBuilder private var controlBar: some View { HStack(spacing: 0) { // Shuffle Button { Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) } } label: { VStack(spacing: 3) { Image(systemName: "shuffle") .font(.system(size: 20)) .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) Text("Shuffle") .font(.caption2) .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) // Repeat Button { let next: RepeatMode switch repeatMode { case .off: next = .all case .all: next = .one case .one: next = .off } Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) } } label: { VStack(spacing: 3) { Image(systemName: repeatMode == .one ? "repeat.1" : "repeat") .font(.system(size: 20)) .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) Text(repeatMode == .off ? "Repeat" : (repeatMode == .one ? "Repeat 1" : "Repeat All")) .font(.caption2) .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) // Clear queue Button { showClearConfirm = true } label: { VStack(spacing: 3) { Image(systemName: "xmark.bin") .font(.system(size: 20)) .foregroundStyle(.secondary) Text("Clear") .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) .disabled(queueItems.isEmpty) .opacity(queueItems.isEmpty ? 0.4 : 1.0) } } // MARK: - Queue List @ViewBuilder private var queueList: some View { ScrollViewReader { proxy in List { ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in let isCurrent = currentIndex == index || item.queueItemId == currentItemId QueueItemRow(item: item, isCurrent: isCurrent) .id(item.queueItemId) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) .contentShape(Rectangle()) .onTapGesture { Task { try? await service.playerManager.playIndex( playerId: playerId, index: index ) } } } .onMove { source, destination in guard let from = source.first else { return } let posShift = destination > from ? destination - from - 1 : destination - from guard posShift != 0 else { return } let itemId = queueItems[from].queueItemId Task { try? await service.playerManager.moveQueueItem( playerId: playerId, queueItemId: itemId, posShift: posShift ) } } } .listStyle(.plain) .environment(\.editMode, .constant(.active)) .onAppear { if let id = currentItemId { proxy.scrollTo(id, anchor: .center) } } .onChange(of: currentItemId) { _, newId in if let newId { withAnimation { proxy.scrollTo(newId, anchor: .center) } } } } } } // MARK: - Queue Item Row private struct QueueItemRow: View { @Environment(MAService.self) private var service let item: MAQueueItem let isCurrent: Bool var body: some View { HStack(spacing: 12) { // Thumbnail CachedAsyncImage(url: service.imageProxyURL( path: item.mediaItem?.imageUrl, provider: item.mediaItem?.imageProvider, size: 96 )) { image in image.resizable().scaledToFill() } placeholder: { RoundedRectangle(cornerRadius: 6) .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: "music.note") .font(.caption) .foregroundStyle(.secondary) } } .frame(width: 48, height: 48) .clipShape(RoundedRectangle(cornerRadius: 6)) // Track info VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { if isCurrent { Image(systemName: "waveform") .font(.caption2) .foregroundStyle(.green) } Text(item.name) .font(.body) .fontWeight(isCurrent ? .semibold : .regular) .foregroundStyle(.primary) .lineLimit(1) } if let artists = item.mediaItem?.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() // Duration if let duration = item.duration ?? item.mediaItem?.duration { Text(formatDuration(duration)) .font(.caption) .foregroundStyle(.secondary) .monospacedDigit() } } .padding(.vertical, 8) .padding(.horizontal, 16) .background(isCurrent ? Color.primary.opacity(0.08) : Color.clear) } private func formatDuration(_ seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return String(format: "%d:%02d", minutes, remainingSeconds) } }