218 lines
8.0 KiB
Swift
218 lines
8.0 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
|
||
|
||
// Auto-tracks live updates via @Observable
|
||
private var player: MAPlayer? {
|
||
service.playerManager.players[playerId]
|
||
}
|
||
|
||
private var mediaItem: MAMediaItem? {
|
||
player?.currentItem?.mediaItem
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Blurred artwork background
|
||
CachedAsyncImage(url: service.imageProxyURL(
|
||
path: mediaItem?.imageUrl,
|
||
provider: mediaItem?.imageProvider,
|
||
size: 64
|
||
)) { image in
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} placeholder: {
|
||
Color.clear
|
||
}
|
||
.ignoresSafeArea()
|
||
.blur(radius: 80)
|
||
.scaleEffect(1.4)
|
||
.opacity(0.5)
|
||
|
||
Rectangle()
|
||
.fill(.ultraThinMaterial)
|
||
.ignoresSafeArea()
|
||
|
||
// Content
|
||
VStack(spacing: 0) {
|
||
// Drag indicator
|
||
Capsule()
|
||
.fill(.secondary.opacity(0.4))
|
||
.frame(width: 36, height: 4)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 8)
|
||
|
||
// Player name
|
||
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()
|
||
|
||
// Balance chevron button
|
||
Color.clear
|
||
.frame(width: 44, height: 44)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 8)
|
||
|
||
// Album art
|
||
GeometryReader { geo in
|
||
let size = min(geo.size.width - 64, geo.size.height)
|
||
CachedAsyncImage(url: service.imageProxyURL(
|
||
path: mediaItem?.imageUrl,
|
||
provider: mediaItem?.imageProvider,
|
||
size: 512
|
||
)) { image in
|
||
image
|
||
.resizable()
|
||
.aspectRatio(1, contentMode: .fill)
|
||
} placeholder: {
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.fill(Color.gray.opacity(0.2))
|
||
.overlay {
|
||
Image(systemName: "music.note")
|
||
.font(.system(size: 56))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.frame(width: size, height: size)
|
||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||
.shadow(color: .black.opacity(0.35), radius: 24, y: 12)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.padding(.horizontal, 32)
|
||
.padding(.vertical, 24)
|
||
|
||
// Track info
|
||
VStack(spacing: 6) {
|
||
Text(player?.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: 24)
|
||
|
||
// 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)
|
||
}
|
||
|
||
Spacer(minLength: 24)
|
||
|
||
// Volume
|
||
if let volume = player?.volume {
|
||
HStack(spacing: 10) {
|
||
Image(systemName: "speaker.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 20)
|
||
|
||
Slider(
|
||
value: Binding(
|
||
get: { Double(volume) },
|
||
set: { newValue in
|
||
Task {
|
||
try? await service.playerManager.setVolume(
|
||
playerId: playerId,
|
||
level: Int(newValue)
|
||
)
|
||
}
|
||
}
|
||
),
|
||
in: 0...100,
|
||
step: 1
|
||
)
|
||
|
||
Image(systemName: "speaker.wave.3.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 20)
|
||
}
|
||
.padding(.horizontal, 32)
|
||
}
|
||
|
||
Spacer(minLength: 32)
|
||
}
|
||
}
|
||
.presentationDetents([.large])
|
||
.presentationDragIndicator(.hidden) // using custom indicator
|
||
}
|
||
}
|