Version 1 in App Store

This commit is contained in:
2026-04-05 19:44:05 +02:00
parent f931c92d94
commit c780be089d
12 changed files with 744 additions and 1484 deletions
+217
View File
@@ -0,0 +1,217 @@
//
// 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
}
}