Version one on the App Store

This commit is contained in:
2026-04-05 19:44:30 +02:00
parent c780be089d
commit 3ebf1763ed
26 changed files with 2088 additions and 842 deletions
+198 -161
View File
@@ -12,82 +12,65 @@ struct PlayerNowPlayingView: View {
@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
// Auto-tracks live updates via @Observable
private var player: MAPlayer? {
service.playerManager.players[playerId]
}
private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[playerId]?.currentItem
}
private var mediaItem: MAMediaItem? {
player?.currentItem?.mediaItem
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)
// ScrollView is the root fills the sheet top-to-bottom, no centering
ScrollView {
VStack(spacing: 16) {
// Drag indicator
Capsule()
.fill(.secondary.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 8)
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
// Header: dismiss + player name
HStack {
Button { dismiss() } label: {
Image(systemName: "chevron.down")
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
// Content
VStack(spacing: 0) {
// Drag indicator
Capsule()
.fill(.secondary.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.bottom, 8)
Spacer()
// Player name
HStack {
Button { dismiss() } label: {
Image(systemName: "chevron.down")
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.primary)
VStack(spacing: 2) {
Text("Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.name ?? "")
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
}
Spacer()
Color.clear
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.padding(.horizontal, 20)
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)
// Album art
CachedAsyncImage(url: service.imageProxyURL(
path: mediaItem?.imageUrl,
provider: mediaItem?.imageProvider,
@@ -95,123 +78,177 @@ struct PlayerNowPlayingView: View {
)) { image in
image
.resizable()
.aspectRatio(1, contentMode: .fill)
.scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 16)
.fill(Color.gray.opacity(0.2))
Color.gray.opacity(0.2)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 56))
.foregroundStyle(.secondary)
}
}
.frame(width: size, height: size)
.frame(width: 260, height: 260)
.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)
// 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: 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)
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)
}
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)
// 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) {
// Mute toggle
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)
// Volume down 5
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)
)
}
}
}
// Volume up +5
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)
}
}
.scrollDisabled(true)
.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)
}
.presentationDetents([.large])
.presentationDragIndicator(.hidden) // using custom indicator
.presentationDragIndicator(.hidden)
}
// 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) }
}
}
}