Files
MobileMusicAssistant/ViewsPlayerNowPlayingView.swift
T

421 lines
14 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 isMuted = false
@State private var preMuteVolume: Double = 50
@State private var showQueue = false
@State private var displayedElapsed: Double = 0
@State private var progressTimer: Timer?
// 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)
}
var body: some View {
VStack(spacing: 0) {
// Header
headerView
// Conditional content area
if showQueue {
PlayerQueueView(playerId: playerId)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
playerContent
.transition(.move(edge: .leading).combined(with: .opacity))
}
Spacer(minLength: 0)
// Progress bar (only in player mode, not queue)
if !showQueue {
progressView
}
// Transport + volume (always visible)
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) {
syncElapsedTime()
}
.onChange(of: player?.state) {
// Restart timer when play state changes
syncElapsedTime()
if player?.state == .playing {
startProgressTimer()
} else {
progressTimer?.invalidate()
progressTimer = nil
}
}
.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(showQueue ? "Up Next" : "Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.name ?? "")
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
}
Spacer()
HStack(spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.3)) {
showQueue.toggle()
}
} label: {
Image(systemName: "list.bullet")
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(showQueue ? Color.accentColor : .primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
if !showQueue, let uri = mediaItem?.uri {
FavoriteButton(uri: uri, size: 22)
.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)
}
}
// 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)
Slider(value: $localVolume, in: 0...100, step: 1) { editing in
isVolumeEditing = editing
if !editing {
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(localVolume)
)
}
}
}
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
ZStack(alignment: .leading) {
Capsule()
.fill(.primary.opacity(0.15))
.frame(height: 4)
Capsule()
.fill(.primary)
.frame(width: geo.size.width * progress, height: 4)
}
}
.frame(height: 4)
// 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: - 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 else { return }
displayedElapsed += 0.5
// Clamp to duration
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) }
}
}
}