Favorites, Queue, Now Playing improved

This commit is contained in:
2026-04-06 11:45:32 +02:00
parent 3ebf1763ed
commit e7e9a59e70
6 changed files with 893 additions and 217 deletions
+211 -159
View File
@@ -16,6 +16,7 @@ struct PlayerNowPlayingView: View {
@State private var isVolumeEditing = false
@State private var isMuted = false
@State private var preMuteVolume: Double = 50
@State private var showQueue = false
// Auto-tracks live updates via @Observable
private var player: MAPlayer? {
@@ -31,169 +32,24 @@ struct PlayerNowPlayingView: View {
}
var body: some View {
// 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)
VStack(spacing: 0) {
// Header
headerView
// 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())
}
// 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()
Spacer(minLength: 0)
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)
}
.padding(.horizontal, 20)
// 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)
// 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)
}
// Transport + volume (always visible)
controlsView
}
.scrollDisabled(true)
.background {
ZStack {
CachedAsyncImage(url: service.imageProxyURL(
@@ -229,6 +85,202 @@ struct PlayerNowPlayingView: View {
.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 ? .accent : .primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
if 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: - Volume Helpers
private func adjustVolume(by delta: Int) {