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
|
@Environment(MAService.self) private var service
|
||||||
let playlist: MAPlaylist
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: 24) {
|
// Blurred Background with Ken Burns Effect
|
||||||
// Playlist Header
|
backgroundArtwork
|
||||||
VStack(spacing: 16) {
|
|
||||||
// Playlist Cover
|
// Content
|
||||||
CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in
|
ScrollView {
|
||||||
image
|
VStack(spacing: 24) {
|
||||||
.resizable()
|
// Playlist Header
|
||||||
.aspectRatio(contentMode: .fill)
|
playlistHeader
|
||||||
} placeholder: {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(Color.gray.opacity(0.2))
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "music.note.list")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 250, height: 250)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.shadow(radius: 10)
|
|
||||||
|
|
||||||
// Playlist Info
|
// Action Buttons
|
||||||
VStack(spacing: 8) {
|
actionButtons
|
||||||
if let owner = playlist.owner {
|
|
||||||
Text("By \(owner)")
|
Divider()
|
||||||
.font(.subheadline)
|
.background(Color.white.opacity(0.3))
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
// Tracklist
|
||||||
|
if isLoading {
|
||||||
if playlist.isEditable {
|
ProgressView()
|
||||||
Label("Editable", systemImage: "pencil")
|
.padding()
|
||||||
.font(.caption)
|
.tint(.white)
|
||||||
.foregroundStyle(.blue)
|
} else if tracks.isEmpty {
|
||||||
}
|
Text("No tracks found")
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
trackList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
|
||||||
|
|
||||||
// TODO: Load playlist tracks
|
|
||||||
Text("Playlist details coming soon")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(playlist.name)
|
.navigationTitle(playlist.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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) {
|
||||||
|
// 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.white.opacity(0.1))
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "music.note.list")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 250, height: 250)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.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(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
|
||||||
|
if playlist.isEditable {
|
||||||
|
Text("•")
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
Label("Editable", systemImage: "pencil")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 isLoading = true
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showError = false
|
@State private var showError = false
|
||||||
@State private var showPlayerPicker = false
|
|
||||||
@State private var selectedRadio: MAMediaItem?
|
@State private var selectedRadio: MAMediaItem?
|
||||||
|
|
||||||
private var players: [MAPlayer] {
|
private var players: [MAPlayer] {
|
||||||
@@ -25,13 +24,13 @@ struct RadiosView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List(radios) { radio in
|
List(radios) { radio in
|
||||||
RadioRow(radio: radio, service: service)
|
Button {
|
||||||
.contentShape(Rectangle())
|
handleRadioTap(radio)
|
||||||
.onTapGesture {
|
} label: {
|
||||||
selectedRadio = radio
|
RadioRow(radio: radio)
|
||||||
showPlayerPicker = true
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
.listRowSeparator(.visible)
|
.listRowSeparator(.visible)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -56,15 +55,21 @@ struct RadiosView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
if let errorMessage { Text(errorMessage) }
|
if let errorMessage { Text(errorMessage) }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPlayerPicker) {
|
.sheet(item: $selectedRadio) { radio in
|
||||||
if let radio = selectedRadio {
|
EnhancedPlayerPickerView(
|
||||||
EnhancedPlayerPickerView(
|
players: players,
|
||||||
players: players,
|
onSelect: { player in
|
||||||
onSelect: { player in
|
Task { await playRadio(radio, on: player) }
|
||||||
Task { await playRadio(radio, on: player) }
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleRadioTap(_ radio: MAMediaItem) {
|
||||||
|
if players.count == 1 {
|
||||||
|
Task { await playRadio(radio, on: players.first!) }
|
||||||
|
} else {
|
||||||
|
selectedRadio = radio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +100,8 @@ struct RadiosView: View {
|
|||||||
// MARK: - Radio Row
|
// MARK: - Radio Row
|
||||||
|
|
||||||
private struct RadioRow: View {
|
private struct RadioRow: View {
|
||||||
|
@Environment(MAService.self) private var service
|
||||||
let radio: MAMediaItem
|
let radio: MAMediaItem
|
||||||
let service: MAService
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
-159
@@ -16,6 +16,7 @@ struct PlayerNowPlayingView: View {
|
|||||||
@State private var isVolumeEditing = false
|
@State private var isVolumeEditing = false
|
||||||
@State private var isMuted = false
|
@State private var isMuted = false
|
||||||
@State private var preMuteVolume: Double = 50
|
@State private var preMuteVolume: Double = 50
|
||||||
|
@State private var showQueue = false
|
||||||
|
|
||||||
// Auto-tracks live updates via @Observable
|
// Auto-tracks live updates via @Observable
|
||||||
private var player: MAPlayer? {
|
private var player: MAPlayer? {
|
||||||
@@ -31,169 +32,24 @@ struct PlayerNowPlayingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// ScrollView is the root — fills the sheet top-to-bottom, no centering
|
VStack(spacing: 0) {
|
||||||
ScrollView {
|
// Header
|
||||||
VStack(spacing: 16) {
|
headerView
|
||||||
// Drag indicator
|
|
||||||
Capsule()
|
|
||||||
.fill(.secondary.opacity(0.4))
|
|
||||||
.frame(width: 36, height: 4)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
// Header: dismiss + player name
|
// Conditional content area
|
||||||
HStack {
|
if showQueue {
|
||||||
Button { dismiss() } label: {
|
PlayerQueueView(playerId: playerId)
|
||||||
Image(systemName: "chevron.down")
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
.font(.title3)
|
} else {
|
||||||
.fontWeight(.semibold)
|
playerContent
|
||||||
.foregroundStyle(.primary)
|
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||||
.frame(width: 44, height: 44)
|
}
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
// Transport + volume (always visible)
|
||||||
Text("Now Playing")
|
controlsView
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.scrollDisabled(true)
|
|
||||||
.background {
|
.background {
|
||||||
ZStack {
|
ZStack {
|
||||||
CachedAsyncImage(url: service.imageProxyURL(
|
CachedAsyncImage(url: service.imageProxyURL(
|
||||||
@@ -229,6 +85,202 @@ struct PlayerNowPlayingView: View {
|
|||||||
.presentationDragIndicator(.hidden)
|
.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
|
// MARK: - Volume Helpers
|
||||||
|
|
||||||
private func adjustVolume(by delta: Int) {
|
private func adjustVolume(by delta: Int) {
|
||||||
|
|||||||
@@ -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