From e7e9a59e70e5219005af82fd09ff7247b5c7dcb3 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 6 Apr 2026 11:45:32 +0200 Subject: [PATCH] Favorites, Queue, Now Playing improved --- .../ViewsComponentsFavoriteButton.swift | 37 ++ .../ViewsLibraryPlaylistDetailView.swift | 369 +++++++++++++++-- .../ViewsLibraryRadiosView.swift | 41 +- ViewsComponentsProviderBadge.swift | 127 ++++++ ViewsPlayerNowPlayingView.swift | 370 ++++++++++-------- ViewsPlayerQueueView.swift | 166 ++++++++ 6 files changed, 893 insertions(+), 217 deletions(-) create mode 100644 Mobile Music Assistant/ViewsComponentsFavoriteButton.swift create mode 100644 ViewsComponentsProviderBadge.swift create mode 100644 ViewsPlayerQueueView.swift diff --git a/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift b/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift new file mode 100644 index 0000000..982dcc2 --- /dev/null +++ b/Mobile Music Assistant/ViewsComponentsFavoriteButton.swift @@ -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) + } +} diff --git a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift index 2c86a0d..81bab7b 100644 --- a/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift +++ b/Mobile Music Assistant/ViewsLibraryPlaylistDetailView.swift @@ -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 { - ScrollView { - VStack(spacing: 24) { - // Playlist Header - VStack(spacing: 16) { - // Playlist Cover - 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)) - .overlay { - Image(systemName: "music.note.list") - .font(.system(size: 60)) - .foregroundStyle(.secondary) - } - } - .frame(width: 250, height: 250) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(radius: 10) + ZStack { + // Blurred Background with Ken Burns Effect + backgroundArtwork + + // Content + ScrollView { + VStack(spacing: 24) { + // Playlist Header + playlistHeader - // Playlist Info - VStack(spacing: 8) { - if let owner = playlist.owner { - Text("By \(owner)") - .font(.subheadline) - .foregroundStyle(.secondary) - } - - if playlist.isEditable { - Label("Editable", systemImage: "pencil") - .font(.caption) - .foregroundStyle(.blue) - } + // 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 } } - .padding(.top) - - // TODO: Load playlist tracks - Text("Playlist details coming soon") - .foregroundStyle(.secondary) - .padding() } } .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) { + // 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 + } } } diff --git a/Mobile Music Assistant/ViewsLibraryRadiosView.swift b/Mobile Music Assistant/ViewsLibraryRadiosView.swift index 837e01a..393a8d2 100644 --- a/Mobile Music Assistant/ViewsLibraryRadiosView.swift +++ b/Mobile Music Assistant/ViewsLibraryRadiosView.swift @@ -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,13 +24,13 @@ struct RadiosView: View { var body: some View { List(radios) { radio in - RadioRow(radio: radio, service: service) - .contentShape(Rectangle()) - .onTapGesture { - selectedRadio = radio - showPlayerPicker = true - } - .listRowSeparator(.visible) + Button { + handleRadioTap(radio) + } label: { + RadioRow(radio: radio) + } + .buttonStyle(.plain) + .listRowSeparator(.visible) } .listStyle(.plain) .overlay { @@ -56,15 +55,21 @@ struct RadiosView: View { } message: { if let errorMessage { Text(errorMessage) } } - .sheet(isPresented: $showPlayerPicker) { - if let radio = selectedRadio { - EnhancedPlayerPickerView( - players: players, - onSelect: { player in - Task { await playRadio(radio, on: player) } - } - ) - } + .sheet(item: $selectedRadio) { radio in + EnhancedPlayerPickerView( + players: players, + onSelect: { player in + 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 private struct RadioRow: View { + @Environment(MAService.self) private var service let radio: MAMediaItem - let service: MAService var body: some View { HStack(spacing: 12) { diff --git a/ViewsComponentsProviderBadge.swift b/ViewsComponentsProviderBadge.swift new file mode 100644 index 0000000..dccf18f --- /dev/null +++ b/ViewsComponentsProviderBadge.swift @@ -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 + } +} diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift index 587d974..06af335 100644 --- a/ViewsPlayerNowPlayingView.swift +++ b/ViewsPlayerNowPlayingView.swift @@ -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) { diff --git a/ViewsPlayerQueueView.swift b/ViewsPlayerQueueView.swift new file mode 100644 index 0000000..1ac84cf --- /dev/null +++ b/ViewsPlayerQueueView.swift @@ -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) + } +}