Files
MobileMusicAssistant/ViewsPlayerNowPlayingView.swift
T

663 lines
24 KiB
Swift
Raw 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
@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
private var player: MAPlayer? {
service.playerManager.players[playerId]
}
private var playerQueue: MAPlayerQueue? {
service.playerManager.playerQueues[playerId]
}
private var currentItem: MAQueueItem? {
playerQueue?.currentItem
}
private var mediaItem: MAMediaItem? {
currentItem?.mediaItem
}
private var trackDuration: Double {
Double(currentItem?.duration ?? 0)
}
// Queue computed properties
private var queueItems: [MAQueueItem] {
service.playerManager.queues[playerId] ?? []
}
private var currentQueueIndex: 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) {
// 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: player?.state) {
syncElapsedTime()
if player?.state == .playing {
startProgressTimer()
} else {
progressTimer?.invalidate()
progressTimer = nil
}
}
.task {
isQueueLoading = true
try? await service.playerManager.loadQueue(playerId: playerId)
isQueueLoading = 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) { }
}
.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("Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.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 player {
HStack(spacing: 48) {
Button {
Task { try? await service.playerManager.previousTrack(playerId: playerId) }
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 30))
.foregroundStyle(.primary)
}
Button {
Task {
if player.state == .playing {
try? await service.playerManager.pause(playerId: playerId)
} else {
try? await service.playerManager.play(playerId: playerId)
}
}
} label: {
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
.symbolEffect(.bounce, value: player.state == .playing)
}
Button {
Task { try? await service.playerManager.nextTrack(playerId: playerId) }
} 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 service.playerManager.setVolume(
playerId: playerId,
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: playerId, 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: playerId,
index: index
)
}
}
}
}
}
}
}
// MARK: - Queue Control Bar
@ViewBuilder
private var queueControlBar: 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: - Progress Helpers
private func syncElapsedTime() {
guard let queue = playerQueue,
let elapsed = queue.elapsedTime else {
displayedElapsed = 0
return
}
if player?.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 player?.state == .playing else { return }
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
Task { @MainActor in
guard player?.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
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) }
}
private func handleMute() {
if isMuted {
let restore = preMuteVolume > 0 ? preMuteVolume : 50
localVolume = restore
isMuted = false
Task { try? await service.playerManager.setVolume(playerId: playerId, level: Int(restore)) }
} else {
preMuteVolume = localVolume
localVolume = 0
isMuted = true
Task { try? await service.playerManager.setVolume(playerId: playerId, level: 0) }
}
}
}