Favorites, Queue, Now Playing improved
This commit is contained in:
+211
-159
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user