Favorites, Queue, Now Playing improved
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// FavoriteButton.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 05.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Reusable heart button for toggling favorites on artists, albums, and tracks.
|
||||
struct FavoriteButton: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let uri: String
|
||||
var size: CGFloat = 22
|
||||
var showInLight: Bool = false
|
||||
|
||||
private var isFavorite: Bool {
|
||||
service.libraryManager.isFavorite(uri: uri)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
Task {
|
||||
await service.libraryManager.toggleFavorite(
|
||||
uri: uri,
|
||||
currentlyFavorite: isFavorite
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
||||
.font(.system(size: size))
|
||||
.foregroundStyle(isFavorite ? .red : (showInLight ? .white.opacity(0.7) : .secondary))
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -11,54 +11,343 @@ struct PlaylistDetailView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let playlist: MAPlaylist
|
||||
|
||||
@State private var tracks: [MAMediaItem] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var showPlayerPicker = false
|
||||
@State private var showEnqueuePicker = false
|
||||
@State private var kenBurnsScale: CGFloat = 1.0
|
||||
|
||||
private var players: [MAPlayer] {
|
||||
Array(service.playerManager.players.values)
|
||||
.filter { $0.available }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Blurred Background with Ken Burns Effect
|
||||
backgroundArtwork
|
||||
|
||||
// Content
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Playlist Header
|
||||
playlistHeader
|
||||
|
||||
// Action Buttons
|
||||
actionButtons
|
||||
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.3))
|
||||
|
||||
// Tracklist
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.padding()
|
||||
.tint(.white)
|
||||
} else if tracks.isEmpty {
|
||||
Text("No tracks found")
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding()
|
||||
} else {
|
||||
trackList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(playlist.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
FavoriteButton(uri: playlist.uri, size: 22, showInLight: true)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadTracks()
|
||||
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
|
||||
kenBurnsScale = 1.15
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPlayerPicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
onSelect: { player in
|
||||
Task { await playPlaylist(on: player) }
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showEnqueuePicker) {
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
title: "Add to Queue on...",
|
||||
onSelect: { player in
|
||||
Task { await enqueuePlaylist(on: player) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background Artwork
|
||||
|
||||
@ViewBuilder
|
||||
private var backgroundArtwork: some View {
|
||||
GeometryReader { geometry in
|
||||
CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.scaleEffect(kenBurnsScale)
|
||||
.blur(radius: 50)
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.7),
|
||||
Color.black.opacity(0.5),
|
||||
Color.black.opacity(0.7)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
.clipped()
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(.systemGray6), Color(.systemGray5)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
Color.black.opacity(0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
// MARK: - Playlist Header
|
||||
|
||||
@ViewBuilder
|
||||
private var playlistHeader: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Playlist Cover
|
||||
// Cover Art
|
||||
CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.overlay {
|
||||
Image(systemName: "music.note.list")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(width: 250, height: 250)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(radius: 10)
|
||||
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
|
||||
|
||||
// Playlist Info
|
||||
VStack(spacing: 8) {
|
||||
if let owner = playlist.owner {
|
||||
Text("By \(owner)")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
ProviderBadge(uri: playlist.uri, imageProvider: playlist.imageProvider)
|
||||
|
||||
if !tracks.isEmpty {
|
||||
Text("\(tracks.count) tracks")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
if playlist.isEditable {
|
||||
Text("•")
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Label("Editable", systemImage: "pencil")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
// TODO: Load playlist tracks
|
||||
Text("Playlist details coming soon")
|
||||
.foregroundStyle(.secondary)
|
||||
// MARK: - Action Buttons
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Play Playlist
|
||||
Button {
|
||||
if players.count == 1 {
|
||||
Task { await playPlaylist(on: players.first!) }
|
||||
} else {
|
||||
showPlayerPicker = true
|
||||
}
|
||||
} label: {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// Add to Queue
|
||||
Button {
|
||||
if players.count == 1 {
|
||||
Task { await enqueuePlaylist(on: players.first!) }
|
||||
} else {
|
||||
showEnqueuePicker = true
|
||||
}
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "text.badge.plus")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.2), Color.white.opacity(0.1)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
}
|
||||
.navigationTitle(playlist.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.padding(.horizontal)
|
||||
.disabled(tracks.isEmpty || players.isEmpty)
|
||||
.opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Track List
|
||||
|
||||
@ViewBuilder
|
||||
private var trackList: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
|
||||
TrackRow(track: track, trackNumber: index + 1, useLightTheme: true)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if players.count == 1 {
|
||||
Task {
|
||||
await playTrack(track, on: players.first!)
|
||||
}
|
||||
} else {
|
||||
showPlayerPicker = true
|
||||
}
|
||||
}
|
||||
|
||||
if index < tracks.count - 1 {
|
||||
Divider()
|
||||
.background(Color.white.opacity(0.2))
|
||||
.padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.black.opacity(0.3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.white.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadTracks() async {
|
||||
isLoading = true
|
||||
do {
|
||||
tracks = try await service.libraryManager.getPlaylistTracks(playlistUri: playlist.uri)
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func playPlaylist(on player: MAPlayer) async {
|
||||
do {
|
||||
try await service.playerManager.playMedia(
|
||||
playerId: player.playerId,
|
||||
uri: playlist.uri
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueuePlaylist(on player: MAPlayer) async {
|
||||
do {
|
||||
try await service.playerManager.enqueueMedia(
|
||||
playerId: player.playerId,
|
||||
uri: playlist.uri
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async {
|
||||
do {
|
||||
try await service.playerManager.playMedia(
|
||||
playerId: player.playerId,
|
||||
uri: track.uri
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ struct RadiosView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var showPlayerPicker = false
|
||||
@State private var selectedRadio: MAMediaItem?
|
||||
|
||||
private var players: [MAPlayer] {
|
||||
@@ -25,12 +24,12 @@ struct RadiosView: View {
|
||||
|
||||
var body: some View {
|
||||
List(radios) { radio in
|
||||
RadioRow(radio: radio, service: service)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
selectedRadio = radio
|
||||
showPlayerPicker = true
|
||||
Button {
|
||||
handleRadioTap(radio)
|
||||
} label: {
|
||||
RadioRow(radio: radio)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.visible)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -56,8 +55,7 @@ struct RadiosView: View {
|
||||
} message: {
|
||||
if let errorMessage { Text(errorMessage) }
|
||||
}
|
||||
.sheet(isPresented: $showPlayerPicker) {
|
||||
if let radio = selectedRadio {
|
||||
.sheet(item: $selectedRadio) { radio in
|
||||
EnhancedPlayerPickerView(
|
||||
players: players,
|
||||
onSelect: { player in
|
||||
@@ -66,6 +64,13 @@ struct RadiosView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRadioTap(_ radio: MAMediaItem) {
|
||||
if players.count == 1 {
|
||||
Task { await playRadio(radio, on: players.first!) }
|
||||
} else {
|
||||
selectedRadio = radio
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRadios() async {
|
||||
@@ -95,8 +100,8 @@ struct RadiosView: View {
|
||||
// MARK: - Radio Row
|
||||
|
||||
private struct RadioRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let radio: MAMediaItem
|
||||
let service: MAService
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// ProviderBadge.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 06.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Small monochrome badge indicating which music provider an item comes from.
|
||||
/// Uses the URI scheme first, then falls back to the image provider field.
|
||||
struct ProviderBadge: View {
|
||||
let uri: String
|
||||
var imageProvider: String? = nil
|
||||
|
||||
private var provider: MusicProvider? {
|
||||
// Try URI scheme first (provider-specific items like subsonic://...)
|
||||
if let fromScheme = MusicProvider.from(scheme: URL(string: uri)?.scheme),
|
||||
fromScheme != .library {
|
||||
return fromScheme
|
||||
}
|
||||
// Fall back to the image provider metadata
|
||||
if let imageProvider, let fromImage = MusicProvider.from(providerKey: imageProvider) {
|
||||
return fromImage
|
||||
}
|
||||
// URI scheme is library:// and no image provider — show library badge
|
||||
if URL(string: uri)?.scheme?.lowercased() == "library" {
|
||||
return .library
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let provider {
|
||||
Image(systemName: provider.icon)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 20, height: 20)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider Mapping
|
||||
|
||||
enum MusicProvider {
|
||||
case library
|
||||
case subsonic
|
||||
case spotify
|
||||
case tidal
|
||||
case qobuz
|
||||
case plex
|
||||
case ytmusic
|
||||
case appleMusic
|
||||
case deezer
|
||||
case soundcloud
|
||||
case tunein
|
||||
case filesystem
|
||||
case jellyfin
|
||||
case dlna
|
||||
|
||||
/// SF Symbol name for this provider.
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .library: return "building.columns.fill"
|
||||
case .subsonic: return "sailboat.fill"
|
||||
case .spotify: return "antenna.radiowaves.left.and.right.circle.fill"
|
||||
case .tidal: return "water.waves"
|
||||
case .qobuz: return "hifispeaker.fill"
|
||||
case .plex: return "play.square.stack.fill"
|
||||
case .ytmusic: return "play.rectangle.fill"
|
||||
case .appleMusic: return "applelogo"
|
||||
case .deezer: return "waveform"
|
||||
case .soundcloud: return "cloud.fill"
|
||||
case .tunein: return "radio.fill"
|
||||
case .filesystem: return "folder.fill"
|
||||
case .jellyfin: return "server.rack"
|
||||
case .dlna: return "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a URI scheme to a known provider.
|
||||
static func from(scheme: String?) -> MusicProvider? {
|
||||
guard let scheme = scheme?.lowercased() else { return nil }
|
||||
|
||||
if scheme == "library" { return .library }
|
||||
if scheme.hasPrefix("subsonic") { return .subsonic }
|
||||
if scheme.hasPrefix("spotify") { return .spotify }
|
||||
if scheme.hasPrefix("tidal") { return .tidal }
|
||||
if scheme.hasPrefix("qobuz") { return .qobuz }
|
||||
if scheme.hasPrefix("plex") { return .plex }
|
||||
if scheme.hasPrefix("ytmusic") { return .ytmusic }
|
||||
if scheme.hasPrefix("apple") { return .appleMusic }
|
||||
if scheme.hasPrefix("deezer") { return .deezer }
|
||||
if scheme.hasPrefix("soundcloud") { return .soundcloud }
|
||||
if scheme.hasPrefix("tunein") { return .tunein }
|
||||
if scheme.hasPrefix("filesystem") { return .filesystem }
|
||||
if scheme.hasPrefix("jellyfin") { return .jellyfin }
|
||||
if scheme.hasPrefix("dlna") { return .dlna }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Match a provider key from image metadata (e.g. "subsonic", "spotify", "filesystem_local").
|
||||
static func from(providerKey key: String) -> MusicProvider? {
|
||||
let k = key.lowercased()
|
||||
|
||||
if k.hasPrefix("subsonic") { return .subsonic }
|
||||
if k.hasPrefix("spotify") { return .spotify }
|
||||
if k.hasPrefix("tidal") { return .tidal }
|
||||
if k.hasPrefix("qobuz") { return .qobuz }
|
||||
if k.hasPrefix("plex") { return .plex }
|
||||
if k.hasPrefix("ytmusic") { return .ytmusic }
|
||||
if k.hasPrefix("apple") { return .appleMusic }
|
||||
if k.hasPrefix("deezer") { return .deezer }
|
||||
if k.hasPrefix("soundcloud") { return .soundcloud }
|
||||
if k.hasPrefix("tunein") { return .tunein }
|
||||
if k.hasPrefix("filesystem") { return .filesystem }
|
||||
if k.hasPrefix("jellyfin") { return .jellyfin }
|
||||
if k.hasPrefix("dlna") { return .dlna }
|
||||
// Common image-only providers — not a music source
|
||||
if k == "lastfm" || k == "musicbrainz" || k == "fanarttv" { return nil }
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -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,16 +32,70 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// ScrollView is the root — fills the sheet top-to-bottom, no centering
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
headerView
|
||||
|
||||
// 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(minLength: 0)
|
||||
|
||||
// Transport + volume (always visible)
|
||||
controlsView
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Header: dismiss + player name
|
||||
HStack {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "chevron.down")
|
||||
@@ -54,7 +109,7 @@ struct PlayerNowPlayingView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text("Now Playing")
|
||||
Text(showQueue ? "Up Next" : "Now Playing")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(player?.name ?? "")
|
||||
@@ -65,10 +120,36 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Color.clear
|
||||
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(
|
||||
@@ -113,6 +194,15 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
.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) {
|
||||
@@ -152,7 +242,6 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
// Volume control
|
||||
HStack(spacing: 10) {
|
||||
// Mute toggle
|
||||
Button { handleMute() } label: {
|
||||
Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.slash")
|
||||
.font(.system(size: 15))
|
||||
@@ -161,7 +250,6 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Volume down –5
|
||||
Button { adjustVolume(by: -5) } label: {
|
||||
Image(systemName: "speaker.fill")
|
||||
.font(.system(size: 13))
|
||||
@@ -181,7 +269,6 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Volume up +5
|
||||
Button { adjustVolume(by: 5) } label: {
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
.font(.system(size: 20))
|
||||
@@ -193,41 +280,6 @@ struct PlayerNowPlayingView: View {
|
||||
.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)
|
||||
}
|
||||
|
||||
// MARK: - Volume Helpers
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// PlayerQueueView.swift
|
||||
// Mobile Music Assistant
|
||||
//
|
||||
// Created by Sven Hanold on 06.04.26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerQueueView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let playerId: String
|
||||
|
||||
@State private var isLoading = false
|
||||
|
||||
private var queueItems: [MAQueueItem] {
|
||||
service.playerManager.queues[playerId] ?? []
|
||||
}
|
||||
|
||||
private var currentIndex: Int? {
|
||||
service.playerManager.playerQueues[playerId]?.currentIndex
|
||||
}
|
||||
|
||||
private var currentItemId: String? {
|
||||
service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading && queueItems.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if queueItems.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text("Queue is empty")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
|
||||
let isCurrent = currentIndex == index
|
||||
|| item.queueItemId == currentItemId
|
||||
|
||||
QueueItemRow(item: item, isCurrent: isCurrent)
|
||||
.id(item.queueItemId)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task {
|
||||
try? await service.playerManager.playIndex(
|
||||
playerId: playerId,
|
||||
index: index
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if index < queueItems.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.onAppear {
|
||||
if let id = currentItemId {
|
||||
proxy.scrollTo(id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: currentItemId) { _, newId in
|
||||
if let newId {
|
||||
withAnimation {
|
||||
proxy.scrollTo(newId, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
isLoading = true
|
||||
try? await service.playerManager.loadQueue(playerId: playerId)
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue Item Row
|
||||
|
||||
private struct QueueItemRow: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let item: MAQueueItem
|
||||
let isCurrent: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
CachedAsyncImage(url: service.imageProxyURL(
|
||||
path: item.mediaItem?.imageUrl,
|
||||
provider: item.mediaItem?.imageProvider,
|
||||
size: 96
|
||||
)) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.overlay {
|
||||
Image(systemName: "music.note")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
// Track info
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
if isCurrent {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(item.name)
|
||||
.font(.body)
|
||||
.fontWeight(isCurrent ? .semibold : .regular)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let artists = item.mediaItem?.artists, !artists.isEmpty {
|
||||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Duration
|
||||
if let duration = item.duration ?? item.mediaItem?.duration {
|
||||
Text(formatDuration(duration))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.background(isCurrent ? Color.primary.opacity(0.08) : Color.clear)
|
||||
}
|
||||
|
||||
private func formatDuration(_ seconds: Int) -> String {
|
||||
let minutes = seconds / 60
|
||||
let remainingSeconds = seconds % 60
|
||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user