Files
MobileMusicAssistant/ViewsPlayerNowPlayingView.swift
2026-04-16 05:48:51 +02:00

681 lines
25 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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) }
}
}
}