681 lines
25 KiB
Swift
681 lines
25 KiB
Swift
//
|
||
// PlayerNowPlayingView.swift
|
||
// Mobile Music Assistant
|
||
//
|
||
// Created by Sven Hanold on 05.04.26.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
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
|
||
@State private var volumeSettleTask: Task<Void, Never>?
|
||
@State private var isMuted = false
|
||
@State private var preMuteVolume: Double = 50
|
||
@State private var displayedElapsed: Double = 0
|
||
@State private var isProgressEditing = false
|
||
@State private var progressSettleTask: Task<Void, Never>?
|
||
@State private var progressTimer: Timer?
|
||
|
||
// Queue scroll trigger
|
||
@State private var scrollToQueue = false
|
||
|
||
// Queue state
|
||
@State private var isQueueLoading = false
|
||
@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[effectivePlayerId]
|
||
}
|
||
|
||
private var currentItem: MAQueueItem? {
|
||
playerQueue?.currentItem
|
||
}
|
||
|
||
private var mediaItem: MAMediaItem? {
|
||
currentItem?.mediaItem
|
||
}
|
||
|
||
private var trackDuration: Double {
|
||
Double(currentItem?.duration ?? 0)
|
||
}
|
||
|
||
// Queue computed properties — all use effectivePlayerId (leader when synced)
|
||
private var queueItems: [MAQueueItem] {
|
||
service.playerManager.queues[effectivePlayerId] ?? []
|
||
}
|
||
|
||
private var currentQueueIndex: Int? {
|
||
service.playerManager.playerQueues[effectivePlayerId]?.currentIndex
|
||
}
|
||
|
||
private var currentItemId: String? {
|
||
service.playerManager.playerQueues[effectivePlayerId]?.currentItem?.queueItemId
|
||
}
|
||
|
||
private var shuffleEnabled: Bool {
|
||
service.playerManager.playerQueues[effectivePlayerId]?.shuffleEnabled ?? false
|
||
}
|
||
|
||
private var repeatMode: RepeatMode {
|
||
service.playerManager.playerQueues[effectivePlayerId]?.repeatMode ?? .off
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Pinned header — always visible
|
||
headerView
|
||
|
||
// Scrollable content (album art + queue only)
|
||
// GeometryReader gives the actual available height so playerContent
|
||
// can fill exactly (viewport − "Up Next" header), making only the
|
||
// caption peek at the bottom as a scroll hint.
|
||
GeometryReader { geo in
|
||
ScrollViewReader { proxy in
|
||
ScrollView(.vertical, showsIndicators: false) {
|
||
VStack(spacing: 0) {
|
||
playerContent
|
||
// Leave ~48 pt so only the "Up Next" header peeks
|
||
.frame(minHeight: geo.size.height - 48)
|
||
|
||
// Queue section — below the fold
|
||
queueSection
|
||
.id("queueSection")
|
||
}
|
||
}
|
||
.onChange(of: scrollToQueue) { _, shouldScroll in
|
||
if shouldScroll {
|
||
withAnimation(.easeInOut(duration: 0.4)) {
|
||
proxy.scrollTo("queueSection", anchor: .top)
|
||
}
|
||
scrollToQueue = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pinned controls — always outside the scroll view, no gesture conflicts
|
||
progressView
|
||
controlsView
|
||
}
|
||
.background {
|
||
ZStack {
|
||
CachedAsyncImage(url: service.imageProxyURL(
|
||
path: mediaItem?.imageUrl,
|
||
provider: mediaItem?.imageProvider,
|
||
size: 64
|
||
)) { image in
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Color.clear
|
||
}
|
||
.blur(radius: 80)
|
||
.scaleEffect(1.4)
|
||
.opacity(0.5)
|
||
|
||
Rectangle()
|
||
.fill(.ultraThinMaterial)
|
||
}
|
||
.ignoresSafeArea()
|
||
}
|
||
.onChange(of: player?.volume) { _, newVolume in
|
||
if !isVolumeEditing, let v = newVolume {
|
||
localVolume = Double(v)
|
||
if v > 0 { isMuted = false }
|
||
}
|
||
}
|
||
.onAppear {
|
||
localVolume = Double(player?.volume ?? 50)
|
||
syncElapsedTime()
|
||
startProgressTimer()
|
||
}
|
||
.onDisappear {
|
||
progressTimer?.invalidate()
|
||
progressTimer = nil
|
||
}
|
||
.onChange(of: playerQueue?.elapsedTime) {
|
||
if !isProgressEditing { syncElapsedTime() }
|
||
}
|
||
.onChange(of: effectivePlayer?.state) {
|
||
syncElapsedTime()
|
||
if effectivePlayer?.state == .playing {
|
||
startProgressTimer()
|
||
} else {
|
||
progressTimer?.invalidate()
|
||
progressTimer = nil
|
||
}
|
||
}
|
||
.task {
|
||
isQueueLoading = true
|
||
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: effectivePlayerId) }
|
||
}
|
||
Button("Cancel", role: .cancel) { }
|
||
}
|
||
.presentationDetents([.large])
|
||
.presentationDragIndicator(.hidden)
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
@ViewBuilder
|
||
private var headerView: some View {
|
||
VStack(spacing: 0) {
|
||
// Drag indicator
|
||
Capsule()
|
||
.fill(.secondary.opacity(0.4))
|
||
.frame(width: 36, height: 4)
|
||
.padding(.top, 8)
|
||
|
||
HStack {
|
||
Button { dismiss() } label: {
|
||
Image(systemName: "chevron.down")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(.primary)
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
Spacer()
|
||
|
||
VStack(spacing: 2) {
|
||
Text(leaderPlayerId != nil ? player?.name ?? "Now Playing" : "Now Playing")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Text(effectivePlayer?.name ?? "")
|
||
.font(.subheadline)
|
||
.fontWeight(.semibold)
|
||
.lineLimit(1)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
HStack(spacing: 4) {
|
||
// Queue icon — scrolls to the queue section below
|
||
Button {
|
||
scrollToQueue = true
|
||
} label: {
|
||
Image(systemName: "list.bullet")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(.primary)
|
||
.frame(width: 44, height: 44)
|
||
.contentShape(Rectangle())
|
||
}
|
||
|
||
if let uri = mediaItem?.uri {
|
||
FavoriteButton(uri: uri, size: 22, itemName: currentItem?.name)
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
}
|
||
}
|
||
|
||
// MARK: - Player Content (album art + track info)
|
||
|
||
@ViewBuilder
|
||
private var playerContent: some View {
|
||
VStack(spacing: 16) {
|
||
Spacer(minLength: 8)
|
||
|
||
// Album art
|
||
CachedAsyncImage(url: service.imageProxyURL(
|
||
path: mediaItem?.imageUrl,
|
||
provider: mediaItem?.imageProvider,
|
||
size: 512
|
||
)) { image in
|
||
image
|
||
.resizable()
|
||
.scaledToFill()
|
||
} placeholder: {
|
||
Color.gray.opacity(0.2)
|
||
.overlay {
|
||
Image(systemName: "music.note")
|
||
.font(.system(size: 56))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(width: 260, height: 260)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.shadow(color: .black.opacity(0.35), radius: 24, y: 12)
|
||
|
||
// Track info
|
||
VStack(spacing: 6) {
|
||
Text(currentItem?.name ?? "–")
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
.lineLimit(2)
|
||
.multilineTextAlignment(.center)
|
||
|
||
if let artists = mediaItem?.artists, !artists.isEmpty {
|
||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
} else if let album = mediaItem?.album {
|
||
Text(album.name)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
.padding(.horizontal, 32)
|
||
|
||
Spacer(minLength: 8)
|
||
}
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
// MARK: - Transport + Volume Controls
|
||
|
||
@ViewBuilder
|
||
private var controlsView: some View {
|
||
VStack(spacing: 16) {
|
||
// Transport controls
|
||
if let ep = effectivePlayer {
|
||
HStack(spacing: 48) {
|
||
Button {
|
||
Task { try? await service.playerManager.previousTrack(playerId: effectivePlayerId) }
|
||
} label: {
|
||
Image(systemName: "backward.fill")
|
||
.font(.system(size: 30))
|
||
.foregroundStyle(.primary)
|
||
}
|
||
|
||
Button {
|
||
Task {
|
||
if ep.state == .playing {
|
||
try? await service.playerManager.pause(playerId: effectivePlayerId)
|
||
} else {
|
||
try? await service.playerManager.play(playerId: effectivePlayerId)
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: ep.state == .playing ? "pause.circle.fill" : "play.circle.fill")
|
||
.font(.system(size: 72))
|
||
.foregroundStyle(.primary)
|
||
.symbolEffect(.bounce, value: ep.state == .playing)
|
||
}
|
||
|
||
Button {
|
||
Task { try? await service.playerManager.nextTrack(playerId: effectivePlayerId) }
|
||
} label: {
|
||
Image(systemName: "forward.fill")
|
||
.font(.system(size: 30))
|
||
.foregroundStyle(.primary)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// Volume control
|
||
HStack(spacing: 10) {
|
||
Button { handleMute() } label: {
|
||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.slash")
|
||
.font(.system(size: 15))
|
||
.foregroundStyle(isMuted ? .primary : .secondary)
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
Button { adjustVolume(by: -5) } label: {
|
||
Image(systemName: "speaker.fill")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
GeometryReader { geo in
|
||
let thumbX = max(0, min(geo.size.width, geo.size.width * localVolume / 100))
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(.primary.opacity(0.15))
|
||
.frame(height: 6)
|
||
Capsule()
|
||
.fill(.primary)
|
||
.frame(width: thumbX, height: 6)
|
||
Circle()
|
||
.fill(.primary)
|
||
.frame(width: 14, height: 14)
|
||
.offset(x: thumbX - 7)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.contentShape(Rectangle())
|
||
.simultaneousGesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
isVolumeEditing = true
|
||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||
}
|
||
.onEnded { value in
|
||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||
Task {
|
||
try? await setVolumeForPlayer(level: Int(localVolume))
|
||
}
|
||
volumeSettleTask?.cancel()
|
||
volumeSettleTask = Task { @MainActor in
|
||
try? await Task.sleep(for: .milliseconds(500))
|
||
guard !Task.isCancelled else { return }
|
||
isVolumeEditing = false
|
||
}
|
||
}
|
||
)
|
||
}
|
||
.frame(height: 28)
|
||
|
||
Button { adjustVolume(by: 5) } label: {
|
||
Image(systemName: "speaker.wave.3.fill")
|
||
.font(.system(size: 20))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.horizontal, 32)
|
||
.padding(.bottom, 32)
|
||
}
|
||
}
|
||
|
||
// MARK: - Progress View
|
||
|
||
@ViewBuilder
|
||
private var progressView: some View {
|
||
let duration = trackDuration
|
||
let progress = duration > 0 ? min(displayedElapsed / duration, 1.0) : 0
|
||
|
||
VStack(spacing: 4) {
|
||
// Progress bar
|
||
GeometryReader { geo in
|
||
let thumbX = max(0, min(geo.size.width, geo.size.width * progress))
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(.primary.opacity(0.15))
|
||
.frame(height: 4)
|
||
Capsule()
|
||
.fill(.primary)
|
||
.frame(width: thumbX, height: 4)
|
||
Circle()
|
||
.fill(.primary)
|
||
.frame(width: 14, height: 14)
|
||
.offset(x: thumbX - 7)
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.contentShape(Rectangle())
|
||
.simultaneousGesture(
|
||
DragGesture(minimumDistance: 0)
|
||
.onChanged { value in
|
||
guard duration > 0 else { return }
|
||
isProgressEditing = true
|
||
progressTimer?.invalidate()
|
||
displayedElapsed = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||
}
|
||
.onEnded { value in
|
||
guard duration > 0 else { return }
|
||
let seekTo = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||
displayedElapsed = seekTo
|
||
Task {
|
||
do {
|
||
try await service.playerManager.seek(playerId: effectivePlayerId, position: seekTo)
|
||
} catch {
|
||
print("❌ Seek failed: \(error)")
|
||
}
|
||
}
|
||
progressSettleTask?.cancel()
|
||
progressSettleTask = Task { @MainActor in
|
||
try? await Task.sleep(for: .milliseconds(500))
|
||
guard !Task.isCancelled else { return }
|
||
isProgressEditing = false
|
||
startProgressTimer()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
.frame(height: 28)
|
||
|
||
// Time labels
|
||
HStack {
|
||
Text(formatTime(displayedElapsed))
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.monospacedDigit()
|
||
Spacer()
|
||
Text("-\(formatTime(max(0, duration - displayedElapsed)))")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
.monospacedDigit()
|
||
}
|
||
}
|
||
.padding(.horizontal, 32)
|
||
.padding(.bottom, 4)
|
||
}
|
||
|
||
// MARK: - Queue Section
|
||
|
||
@ViewBuilder
|
||
private var queueSection: some View {
|
||
VStack(spacing: 0) {
|
||
// Section header
|
||
HStack {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chevron.up")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
Text("Up Next")
|
||
.font(.headline)
|
||
.fontWeight(.bold)
|
||
}
|
||
Spacer()
|
||
if !queueItems.isEmpty {
|
||
Text("\(queueItems.count) tracks")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 8)
|
||
|
||
// Control bar (shuffle / repeat / clear)
|
||
queueControlBar
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 10)
|
||
|
||
Divider()
|
||
|
||
// Queue items
|
||
if isQueueLoading && queueItems.isEmpty {
|
||
ProgressView()
|
||
.frame(height: 100)
|
||
.frame(maxWidth: .infinity)
|
||
} else if queueItems.isEmpty {
|
||
Text("Queue is empty")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
.frame(height: 100)
|
||
.frame(maxWidth: .infinity)
|
||
} else {
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
|
||
let isCurrent = currentQueueIndex == index || item.queueItemId == currentItemId
|
||
QueueItemRow(item: item, isCurrent: isCurrent)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
Task {
|
||
try? await service.playerManager.playIndex(
|
||
playerId: effectivePlayerId,
|
||
index: index
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Queue Control Bar
|
||
|
||
@ViewBuilder
|
||
private var queueControlBar: some View {
|
||
HStack(spacing: 0) {
|
||
// Shuffle
|
||
Button {
|
||
Task { try? await service.playerManager.setShuffle(playerId: effectivePlayerId, 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: effectivePlayerId, 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: - Progress Helpers
|
||
|
||
private func syncElapsedTime() {
|
||
guard let queue = playerQueue,
|
||
let elapsed = queue.elapsedTime else {
|
||
displayedElapsed = 0
|
||
return
|
||
}
|
||
|
||
if effectivePlayer?.state == .playing,
|
||
let lastUpdated = queue.elapsedTimeLastUpdated {
|
||
let serverNow = Date().timeIntervalSince1970
|
||
let delta = serverNow - lastUpdated
|
||
displayedElapsed = elapsed + max(0, delta)
|
||
} else {
|
||
displayedElapsed = elapsed
|
||
}
|
||
}
|
||
|
||
private func startProgressTimer() {
|
||
progressTimer?.invalidate()
|
||
guard effectivePlayer?.state == .playing else { return }
|
||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||
Task { @MainActor in
|
||
guard effectivePlayer?.state == .playing, !isProgressEditing else { return }
|
||
displayedElapsed += 0.5
|
||
if trackDuration > 0 {
|
||
displayedElapsed = min(displayedElapsed, trackDuration)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func formatTime(_ seconds: Double) -> String {
|
||
let totalSeconds = Int(max(0, seconds))
|
||
let m = totalSeconds / 60
|
||
let s = totalSeconds % 60
|
||
return String(format: "%d:%02d", m, s)
|
||
}
|
||
|
||
// 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 setVolumeForPlayer(level: newVolume) }
|
||
}
|
||
|
||
private func handleMute() {
|
||
if isMuted {
|
||
let restore = preMuteVolume > 0 ? preMuteVolume : 50
|
||
localVolume = restore
|
||
isMuted = false
|
||
Task { try? await setVolumeForPlayer(level: Int(restore)) }
|
||
} else {
|
||
preMuteVolume = localVolume
|
||
localVolume = 0
|
||
isMuted = true
|
||
Task { try? await setVolumeForPlayer(level: 0) }
|
||
}
|
||
}
|
||
}
|