From e9462f6d916f859bcc515c2d27c883de1e3024ba Mon Sep 17 00:00:00 2001 From: Sven Date: Thu, 16 Apr 2026 05:48:51 +0200 Subject: [PATCH] Version 1.5: Groups --- Mobile Music Assistant/Localizable.xcstrings | 380 +++++++++++++++--- .../ServicesMAPlayerManager.swift | 7 +- .../ServicesMAService.swift | 36 +- .../ServicesMAWebSocketClient.swift | 3 +- .../ViewsFavoritesView.swift | 124 +++++- Mobile Music Assistant/ViewsMainTabView.swift | 376 +++++++++++++---- Mobile Music Assistant/ViewsRootView.swift | 148 ++++++- ViewsPlayerNowPlayingView.swift | 88 ++-- 8 files changed, 959 insertions(+), 203 deletions(-) diff --git a/Mobile Music Assistant/Localizable.xcstrings b/Mobile Music Assistant/Localizable.xcstrings index 756f60e..cef33e0 100644 --- a/Mobile Music Assistant/Localizable.xcstrings +++ b/Mobile Music Assistant/Localizable.xcstrings @@ -297,6 +297,72 @@ } } }, + "Add \"%@\" to Group?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" zur Gruppe hinzufügen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Añadir \"%@\" al grupo?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter « %@ » au groupe ?" + } + } + } + }, + "Add to Group" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu Gruppe hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir al grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter au groupe" + } + } + } + }, + "Add to Group?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zur Gruppe hinzufügen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Añadir al grupo?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter au groupe ?" + } + } + } + }, "Add to Queue" : { "localizations" : { "de" : { @@ -672,50 +738,6 @@ } } }, - "Clear Queue" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Warteschlange leeren" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaciar cola" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vider la file" - } - } - } - }, - "Clear the entire queue?" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die gesamte Warteschlange leeren?" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Vaciar toda la cola?" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vider toute la file ?" - } - } - } - }, "Clear Cache" : { "localizations" : { "de" : { @@ -760,24 +782,46 @@ } } }, - "This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again." : { + "Clear Queue" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Dadurch werden alle lokal gespeicherten Bilder und Bibliotheksdaten gelöscht. Der nächste Start oder das nächste Laden kann länger dauern, während alles neu abgerufen wird." + "value" : "Warteschlange leeren" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Esto eliminará todas las ilustraciones y datos de biblioteca almacenados localmente. El siguiente inicio o recarga puede tardar más mientras todo se vuelve a descargar." + "value" : "Vaciar cola" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Cela supprimera toutes les illustrations et données de bibliothèque stockées localement. Le prochain démarrage ou rechargement peut prendre plus de temps pendant que tout est à nouveau récupéré." + "value" : "Vider la file" + } + } + } + }, + "Clear the entire queue?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die gesamte Warteschlange leeren?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Vaciar toda la cola?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vider toute la file ?" } } } @@ -937,6 +981,28 @@ } } }, + "Create Group" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppe erstellen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crear grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer un groupe" + } + } + } + }, "Dark" : { "localizations" : { "de" : { @@ -1003,6 +1069,28 @@ } } }, + "Dissolve Group" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppe auflösen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disolver grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dissoudre le groupe" + } + } + } + }, "Do you find this app useful? Support the development by buying the developer a virtual record." : { "localizations" : { "de" : { @@ -1025,6 +1113,29 @@ } } }, + "Drop to Group" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loslassen zum Gruppieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soltar para agrupar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déposer pour grouper" + } + } + } + }, "Editable" : { "localizations" : { "de" : { @@ -1202,6 +1313,50 @@ } } }, + "Group \"%@\" with \"%@\"?" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%1$@\" mit \"%2$@\" gruppieren?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Agrupar \"%1$@\" con \"%2$@\"?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regrouper « %1$@ » avec « %2$@ » ?" + } + } + } + }, + "Group dissolved" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gruppe aufgelöst" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grupo disuelto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groupe dissous" + } + } + } + }, "Hello, world!" : { "comment" : "A greeting displayed in the main view of the app.", "isCommentAutoGenerated" : true @@ -1383,8 +1538,11 @@ } } }, + "Mobile MA" : { + "comment" : "The name of the app.", + "isCommentAutoGenerated" : true + }, "Music Assistant" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1604,6 +1762,10 @@ } } }, + "No Favorite Songs" : { + "comment" : "A title for a view that shows when a user has no favorite songs.", + "isCommentAutoGenerated" : true + }, "No Players Found" : { "localizations" : { "de" : { @@ -1803,6 +1965,7 @@ } }, "Now Playing" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1890,6 +2053,28 @@ } } }, + "Player removed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player entfernt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproductor eliminado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecteur retiré" + } + } + } + }, "Players" : { "localizations" : { "de" : { @@ -1912,6 +2097,28 @@ } } }, + "Players & Groups" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player & Gruppen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reproductores y grupos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecteurs et groupes" + } + } + } + }, "Playlists" : { "localizations" : { "de" : { @@ -2066,6 +2273,29 @@ } } }, + "Remove from Group" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus Gruppe entfernen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar del grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retirer du groupe" + } + } + } + }, "Repeat" : { "localizations" : { "de" : { @@ -2352,6 +2582,10 @@ } } }, + "Songs" : { + "comment" : "Title of the songs tab in the favorites view.", + "isCommentAutoGenerated" : true + }, "Status" : { "localizations" : { "de" : { @@ -2396,6 +2630,28 @@ } } }, + "Synced to: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisiert mit: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sincronizado con: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronisé avec : %@" + } + } + } + }, "System" : { "localizations" : { "de" : { @@ -2506,6 +2762,10 @@ } } }, + "Tap the heart icon on any song to add it here." : { + "comment" : "A description of how to add a song to the user's favorites.", + "isCommentAutoGenerated" : true + }, "Thank You!" : { "localizations" : { "de" : { @@ -2528,6 +2788,28 @@ } } }, + "This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dadurch werden alle lokal gespeicherten Bilder und Bibliotheksdaten gelöscht. Der nächste Start oder das nächste Laden kann länger dauern, während alles neu abgerufen wird." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esto eliminará todas las ilustraciones y datos de biblioteca almacenados localmente. El siguiente inicio o recarga puede tardar más mientras todo se vuelve a descargar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cela supprimera toutes les illustrations et données de bibliothèque stockées localement. Le prochain démarrage ou rechargement peut prendre plus de temps pendant que tout est à nouveau récupéré." + } + } + } + }, "Tracks" : { "localizations" : { "de" : { diff --git a/Mobile Music Assistant/ServicesMAPlayerManager.swift b/Mobile Music Assistant/ServicesMAPlayerManager.swift index 583219d..4b7b848 100644 --- a/Mobile Music Assistant/ServicesMAPlayerManager.swift +++ b/Mobile Music Assistant/ServicesMAPlayerManager.swift @@ -236,8 +236,13 @@ final class MAPlayerManager { try await service.seek(playerId: playerId, position: position) } + func setGroupVolume(playerId: String, level: Int) async throws { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } + try await service.setGroupVolume(playerId: playerId, level: level) + } + func setVolume(playerId: String, level: Int) async throws { - guard let service else { + guard let service else { throw MAWebSocketClient.ClientError.notConnected } try await service.setVolume(playerId: playerId, level: level) diff --git a/Mobile Music Assistant/ServicesMAService.swift b/Mobile Music Assistant/ServicesMAService.swift index a9318a3..3c8969f 100644 --- a/Mobile Music Assistant/ServicesMAService.swift +++ b/Mobile Music Assistant/ServicesMAService.swift @@ -128,20 +128,20 @@ final class MAService { ) } - /// Sync a player to a target (target becomes the sync leader) + /// Group a player under a target leader (MA 2.x: players/cmd/group) func syncPlayer(playerId: String, targetPlayerId: String) async throws { - logger.debug("Syncing player \(playerId) to \(targetPlayerId)") + logger.debug("Grouping player \(playerId) under \(targetPlayerId)") _ = try await webSocketClient.sendCommand( - "players/cmd/sync", - args: ["player_id": playerId, "target_player_id": targetPlayerId] + "players/cmd/group", + args: ["player_id": playerId, "target_player": targetPlayerId] ) } - /// Remove a player from its sync group (or dissolve the group if called on the leader) + /// Remove a player from its group (MA 2.x: players/cmd/ungroup) func unsyncPlayer(playerId: String) async throws { - logger.debug("Unsyncing player \(playerId)") + logger.debug("Ungrouping player \(playerId)") _ = try await webSocketClient.sendCommand( - "players/cmd/unsync", + "players/cmd/ungroup", args: ["player_id": playerId] ) } @@ -160,6 +160,16 @@ final class MAService { logger.debug("Seek response: errorCode=\(String(describing: response.errorCode)) details=\(String(describing: response.details))") } + /// Set group master volume (0-100) — scales all members proportionally + func setGroupVolume(playerId: String, level: Int) async throws { + let clampedLevel = max(0, min(100, level)) + logger.debug("Setting group volume to \(clampedLevel) on leader \(playerId)") + _ = try await webSocketClient.sendCommand( + "players/cmd/group_volume", + args: ["player_id": playerId, "volume_level": clampedLevel] + ) + } + /// Set volume (0-100) func setVolume(playerId: String, level: Int) async throws { let clampedLevel = max(0, min(100, level)) @@ -329,6 +339,18 @@ final class MAService { ) } + /// Get tracks from the library (with optional pagination and favorite filter) + func getTracks(favorite: Bool? = nil, limit: Int = 50, offset: Int = 0) async throws -> [MAMediaItem] { + logger.debug("Fetching tracks (limit: \(limit), offset: \(offset), favorite: \(String(describing: favorite)))") + var args: [String: Any] = ["limit": limit, "offset": offset] + if let favorite { args["favorite"] = favorite } + return try await webSocketClient.sendCommand( + "music/tracks/library_items", + args: args, + resultType: [MAMediaItem].self + ) + } + /// Get radio stations func getRadios() async throws -> [MAMediaItem] { logger.debug("Fetching radios") diff --git a/Mobile Music Assistant/ServicesMAWebSocketClient.swift b/Mobile Music Assistant/ServicesMAWebSocketClient.swift index 1cc96f1..cb77d4d 100644 --- a/Mobile Music Assistant/ServicesMAWebSocketClient.swift +++ b/Mobile Music Assistant/ServicesMAWebSocketClient.swift @@ -274,6 +274,7 @@ final class MAWebSocketClient { // Check for error if let errorCode = response.errorCode { let errorMsg = response.details ?? "Error code: \(errorCode)" + logger.error("Server error \(errorCode) for message \(messageId): \(errorMsg)") continuation.resume(throwing: ClientError.serverError(errorMsg)) } else { continuation.resume(returning: response) @@ -363,7 +364,7 @@ final class MAWebSocketClient { throw ClientError.decodingError(NSError(domain: "Encoding", code: -1)) } - logger.debug("Sending command: \(command) (ID: \(messageId))") + logger.debug("Sending command: \(command) (ID: \(messageId)) payload: \(json)") // Send message and wait for response return try await withCheckedThrowingContinuation { continuation in diff --git a/Mobile Music Assistant/ViewsFavoritesView.swift b/Mobile Music Assistant/ViewsFavoritesView.swift index 320a243..a8c87b0 100644 --- a/Mobile Music Assistant/ViewsFavoritesView.swift +++ b/Mobile Music Assistant/ViewsFavoritesView.swift @@ -9,12 +9,13 @@ import SwiftUI import UIKit enum FavoritesTab: CaseIterable { - case artists, albums, radios, podcasts + case artists, albums, songs, radios, podcasts var title: LocalizedStringKey { switch self { case .artists: return "Artists" case .albums: return "Albums" + case .songs: return "Songs" case .radios: return "Radios" case .podcasts: return "Podcasts" } @@ -38,6 +39,7 @@ struct FavoritesView: View { switch selectedTab { case .artists: FavoriteArtistsSection() case .albums: FavoriteAlbumsSection() + case .songs: FavoriteSongsSection() case .radios: FavoriteRadiosSection() case .podcasts: FavoritePodcastsSection() } @@ -237,6 +239,126 @@ private struct FavoriteAlbumsSection: View { } } +// MARK: - Favorite Songs + +private struct FavoriteSongsSection: View { + @Environment(MAService.self) private var service + + @State private var tracks: [MAMediaItem] = [] + @State private var isLoading = true + @State private var errorMessage: String? + @State private var showError = false + @State private var selectedTrackIndex: Int? + @State private var showPlayerPicker = false + + private var players: [MAPlayer] { + Array(service.playerManager.players.values) + .filter { $0.available } + .sorted { $0.name < $1.name } + } + + private var nowPlayingURIs: Set { + Set(service.playerManager.playerQueues.values.compactMap { + $0.currentItem?.mediaItem?.uri + }) + } + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if tracks.isEmpty { + ContentUnavailableView( + "No Favorite Songs", + systemImage: "heart.slash", + description: Text("Tap the heart icon on any song to add it here.") + ) + } else { + List { + ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in + Button { + handleTrackTap(index: index) + } label: { + TrackRow( + track: track, + trackNumber: index + 1, + isPlaying: nowPlayingURIs.contains(track.uri) + ) + } + .buttonStyle(.plain) + .listRowSeparator(.visible) + } + } + .listStyle(.plain) + } + } + .task { + await loadTracks() + } + .refreshable { + await loadTracks() + } + .alert("Error", isPresented: $showError) { + Button("OK", role: .cancel) { } + } message: { + if let errorMessage { Text(errorMessage) } + } + .sheet(isPresented: $showPlayerPicker) { + EnhancedPlayerPickerView( + players: players, + showNowPlayingOnSelect: true, + onSelect: { player in + if let index = selectedTrackIndex { + Task { await playFrom(index: index, on: player) } + } + } + ) + } + } + + private func handleTrackTap(index: Int) { + if players.count == 1 { + Task { await playFrom(index: index, on: players.first!) } + } else { + selectedTrackIndex = index + showPlayerPicker = true + } + } + + private func loadTracks() async { + isLoading = true + errorMessage = nil + do { + // Fetch all favorited tracks from the server directly + var allTracks: [MAMediaItem] = [] + var offset = 0 + let pageSize = 50 + var hasMore = true + while hasMore { + let page = try await service.getTracks(favorite: true, limit: pageSize, offset: offset) + allTracks.append(contentsOf: page) + offset += page.count + hasMore = page.count >= pageSize + } + tracks = allTracks.sorted { $0.name.lowercased() < $1.name.lowercased() } + } catch { + errorMessage = error.localizedDescription + showError = true + } + isLoading = false + } + + private func playFrom(index: Int, on player: MAPlayer) async { + let uris = tracks[index...].map { $0.uri } + do { + try await service.playerManager.playMedia(playerId: player.playerId, uris: uris) + } catch { + errorMessage = error.localizedDescription + showError = true + } + } +} + // MARK: - Favorite Radios private struct FavoriteRadiosSection: View { diff --git a/Mobile Music Assistant/ViewsMainTabView.swift b/Mobile Music Assistant/ViewsMainTabView.swift index 20ede2b..470f18a 100644 --- a/Mobile Music Assistant/ViewsMainTabView.swift +++ b/Mobile Music Assistant/ViewsMainTabView.swift @@ -7,6 +7,9 @@ import SwiftUI import StoreKit +import OSLog + +private let syncLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "MobileMA", category: "PlayerSync") struct MainTabView: View { @Environment(MAService.self) private var service @@ -51,11 +54,18 @@ struct MainTabView: View { // MARK: - Placeholder Views (to be implemented in Phase 2+) +/// Carries which player to show in Now Playing and, for sync members, who the leader is. +private struct NowPlayingTarget: Identifiable { + let playerId: String + let leaderPlayerId: String? + var id: String { playerId } +} + struct PlayerListView: View { @Environment(MAService.self) private var service @State private var isLoading = false @State private var errorMessage: String? - @State private var nowPlayingPlayer: MAPlayer? + @State private var nowPlayingTarget: NowPlayingTarget? private var allPlayers: [MAPlayer] { Array(service.playerManager.players.values) @@ -103,14 +113,18 @@ struct PlayerListView: View { VStack(spacing: 12) { // Groups shown at the top ForEach(groupLeaders) { leader in - let memberNames = leader.groupChilds - .compactMap { service.playerManager.players[$0]?.name } + let members = leader.groupChilds.compactMap { service.playerManager.players[$0] } PlayerGroupRow( leader: leader, - memberNames: memberNames, - onTap: { nowPlayingPlayer = leader }, + members: members, + onTap: { player, leaderPid in + nowPlayingTarget = NowPlayingTarget(playerId: player.playerId, leaderPlayerId: leaderPid) + }, onDissolve: { Task { try? await service.playerManager.unsyncPlayer(playerId: leader.playerId) } + }, + onRemoveMember: { member in + Task { try? await service.playerManager.unsyncPlayer(playerId: member.playerId) } } ) } @@ -118,7 +132,7 @@ struct PlayerListView: View { // Solo players — drag handle initiates drag, card accepts drops ForEach(soloPlayers) { player in PlayerRow(player: player) { - nowPlayingPlayer = player + nowPlayingTarget = NowPlayingTarget(playerId: player.playerId, leaderPlayerId: nil) } } } @@ -127,7 +141,7 @@ struct PlayerListView: View { } } } - .navigationTitle("Players") + .navigationTitle("Players & Groups") .withMANavigation() .task { await loadPlayers() @@ -135,8 +149,8 @@ struct PlayerListView: View { .refreshable { await loadPlayers() } - .sheet(item: $nowPlayingPlayer) { selectedPlayer in - PlayerNowPlayingView(playerId: selectedPlayer.playerId) + .sheet(item: $nowPlayingTarget) { target in + PlayerNowPlayingView(playerId: target.playerId, leaderPlayerId: target.leaderPlayerId) .environment(service) } } @@ -158,10 +172,17 @@ struct PlayerListView: View { struct PlayerGroupRow: View { @Environment(MAService.self) private var service + @Environment(MAToastManager.self) private var toastManager let leader: MAPlayer - let memberNames: [String] - let onTap: () -> Void + let members: [MAPlayer] + /// Called with (tappedPlayer, leaderPlayerId). leaderPlayerId is nil when the leader itself is tapped. + let onTap: (MAPlayer, String?) -> Void let onDissolve: () -> Void + let onRemoveMember: (MAPlayer) -> Void + + @State private var showDissolveConfirm = false + @State private var isDropTarget = false + @State private var pendingDraggedId: String? = nil private var currentItem: MAQueueItem? { service.playerManager.playerQueues[leader.playerId]?.currentItem @@ -169,94 +190,215 @@ struct PlayerGroupRow: View { private var mediaItem: MAMediaItem? { currentItem?.mediaItem } private var groupName: String { - ([leader.name] + memberNames).joined(separator: " + ") + ([leader.name] + members.map(\.name)).joined(separator: " + ") } var body: some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Image(systemName: "speaker.2.fill") - .font(.caption) - .foregroundStyle(.blue) - if leader.state == .playing { - Image(systemName: "waveform") + VStack(spacing: 8) { + // Leader card + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "speaker.2.fill") .font(.caption) - .foregroundStyle(.green) + .foregroundStyle(.blue) + if leader.state == .playing { + Image(systemName: "waveform") + .font(.caption) + .foregroundStyle(.green) + } + Text(groupName) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) } - Text(groupName) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(1) - } - if let item = currentItem { - Text(item.name) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - if let artists = mediaItem?.artists, !artists.isEmpty { - Text(artists.map { $0.name }.joined(separator: ", ")) - .font(.caption) + if let item = currentItem { + Text(item.name) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + if let artists = mediaItem?.artists, !artists.isEmpty { + Text(artists.map { $0.name }.joined(separator: ", ")) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } else { + Text(leader.state == .off ? "Powered Off" : "No Track Playing") + .font(.subheadline) .foregroundStyle(.tertiary) .lineLimit(1) } - } else { - Text(leader.state == .off ? "Powered Off" : "No Track Playing") - .font(.subheadline) - .foregroundStyle(.tertiary) - .lineLimit(1) + } + + Spacer() + + // Play/pause + Button { + Task { + if leader.state == .playing { + try? await service.playerManager.pause(playerId: leader.playerId) + } else { + try? await service.playerManager.play(playerId: leader.playerId) + } + } + } label: { + Image(systemName: leader.state == .playing ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 36)) + .foregroundStyle(leader.state == .playing ? .green : .secondary) + .symbolEffect(.bounce, value: leader.state == .playing) + } + .buttonStyle(.plain) + + // Dissolve group button + Button { showDissolveConfirm = true } label: { + Image(systemName: "xmark.circle") + .font(.system(size: 22)) + .foregroundStyle(.red.opacity(0.7)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background { + ZStack { + CachedAsyncImage(url: service.imageProxyURL( + path: mediaItem?.imageUrl, + provider: mediaItem?.imageProvider, + size: 256 + )) { image in + image.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + Color.clear + } + .blur(radius: 20) + .scaleEffect(1.1) + .clipped() + Rectangle().fill(.ultraThinMaterial) } } - - Spacer() - - // Play/pause - Button { - Task { - if leader.state == .playing { - try? await service.playerManager.pause(playerId: leader.playerId) - } else { - try? await service.playerManager.play(playerId: leader.playerId) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .contentShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + if isDropTarget { + RoundedRectangle(cornerRadius: 16) + .stroke(Color.accentColor, lineWidth: 2) + .background(RoundedRectangle(cornerRadius: 16).fill(Color.accentColor.opacity(0.08))) + } + } + .onTapGesture { onTap(leader, nil) } + .dropDestination(for: String.self) { items, _ in + guard let draggedId = items.first, + draggedId != leader.playerId, + !(service.playerManager.players[draggedId]?.isGroupLeader ?? false), + !(service.playerManager.players[draggedId]?.isSyncMember ?? false) + else { return false } + pendingDraggedId = draggedId + return true + } isTargeted: { targeted in + isDropTarget = targeted + } + .confirmationDialog( + pendingDraggedId.flatMap { service.playerManager.players[$0]?.name } + .map { "Add \"\($0)\" to Group?" } ?? "Add to Group?", + isPresented: Binding(get: { pendingDraggedId != nil }, set: { if !$0 { pendingDraggedId = nil } }), + titleVisibility: .visible + ) { + Button(String(localized: "Add to Group")) { + guard let draggedId = pendingDraggedId else { return } + pendingDraggedId = nil + Task { + do { + try await service.playerManager.syncPlayer(playerId: draggedId, targetPlayerId: leader.playerId) + } catch { + let msg = error.localizedDescription + syncLogger.error("syncPlayer failed: \(msg)") + await MainActor.run { + toastManager.show(msg, icon: "exclamationmark.triangle", iconColor: .red) + } + } } } - } label: { - Image(systemName: leader.state == .playing ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 36)) - .foregroundStyle(leader.state == .playing ? .green : .secondary) - .symbolEffect(.bounce, value: leader.state == .playing) + Button("Cancel", role: .cancel) { pendingDraggedId = nil } } - .buttonStyle(.plain) - - // Dissolve group button - Button(action: onDissolve) { - Image(systemName: "xmark.circle") - .font(.system(size: 22)) - .foregroundStyle(.red.opacity(0.7)) - } - .buttonStyle(.plain) - } - .padding(.horizontal, 16) - .padding(.vertical, 14) - .background { - ZStack { - CachedAsyncImage(url: service.imageProxyURL( - path: mediaItem?.imageUrl, - provider: mediaItem?.imageProvider, - size: 256 - )) { image in - image.resizable().aspectRatio(contentMode: .fill) - } placeholder: { - Color.clear + .confirmationDialog( + String(localized: "Dissolve Group"), + isPresented: $showDissolveConfirm, + titleVisibility: .visible + ) { + Button(String(localized: "Dissolve Group"), role: .destructive) { + onDissolve() + toastManager.show(String(localized: "Group dissolved"), icon: "speaker.slash", iconColor: .orange) } - .blur(radius: 20) - .scaleEffect(1.1) - .clipped() - Rectangle().fill(.ultraThinMaterial) + Button("Cancel", role: .cancel) {} + } + + // Indented member sub-cards + ForEach(members) { member in + PlayerMemberRow( + member: member, + leaderName: leader.name, + onTap: { onTap(member, leader.playerId) }, + onRemove: { + onRemoveMember(member) + toastManager.show(String(localized: "Player removed"), icon: "minus.circle", iconColor: .orange) + } + ) + .padding(.leading, 24) } } - .clipShape(RoundedRectangle(cornerRadius: 16)) - .contentShape(RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - Player Member Row + +struct PlayerMemberRow: View { + let member: MAPlayer + let leaderName: String + let onTap: () -> Void + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 0) { + // Vertical connection line + Rectangle() + .fill(Color.blue.opacity(0.3)) + .frame(width: 2) + .padding(.vertical, 8) + .padding(.trailing, 10) + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "link") + .font(.caption) + .foregroundStyle(.blue) + Text(member.name) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + } + Text("Synced to: \(leaderName)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Button(action: onRemove) { + Image(systemName: "minus.circle") + .font(.system(size: 22)) + .foregroundStyle(.orange.opacity(0.8)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + .contentShape(RoundedRectangle(cornerRadius: 12)) .onTapGesture { onTap() } } } @@ -265,9 +407,12 @@ struct PlayerGroupRow: View { struct PlayerRow: View { @Environment(MAService.self) private var service + @Environment(MAToastManager.self) private var toastManager let player: MAPlayer let onTap: () -> Void @State private var isDropTarget = false + /// Non-nil when a drop landed and we're waiting for the user to confirm grouping. + @State private var pendingDraggedId: String? = nil private var currentItem: MAQueueItem? { service.playerManager.playerQueues[player.playerId]?.currentItem @@ -279,14 +424,11 @@ struct PlayerRow: View { var body: some View { HStack(spacing: 12) { - // Drag handle — long-press this icon then drag onto another player to group them. - // Keeping it on a small dedicated view avoids conflicts with the ScrollView gesture. + // Visual drag affordance — the whole card is draggable via .draggable below. Image(systemName: "line.3.horizontal") .font(.body) .foregroundStyle(.secondary) .frame(width: 24, height: 44) - .contentShape(Rectangle()) - .draggable(player.playerId) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { @@ -373,14 +515,70 @@ struct PlayerRow: View { } } .onTapGesture { onTap() } - // The full card is the drop target — drop a player from another card's handle here + // Long-press anywhere on the card to initiate a drag onto another player's card. + .draggable(player.playerId) + // Guard: no self-drop, no drop onto a sync member, no drop from a group leader. .dropDestination(for: String.self) { items, _ in - guard let draggedId = items.first, draggedId != player.playerId else { return false } - Task { try? await service.playerManager.syncPlayer(playerId: draggedId, targetPlayerId: player.playerId) } + guard let draggedId = items.first, + draggedId != player.playerId, + !player.isSyncMember, + !(service.playerManager.players[draggedId]?.isGroupLeader ?? false) + else { return false } + // Show confirmation dialog instead of grouping immediately + pendingDraggedId = draggedId return true } isTargeted: { targeted in isDropTarget = targeted } + .confirmationDialog(groupConfirmTitle, isPresented: Binding( + get: { pendingDraggedId != nil }, + set: { if !$0 { pendingDraggedId = nil } } + ), titleVisibility: .visible) { + Button("Create Group") { + guard let draggedId = pendingDraggedId else { return } + pendingDraggedId = nil + let draggedPlayer = service.playerManager.players[draggedId] + // The playing player becomes the leader (targetPlayerId). + // If neither or both are playing, the target (this card) is the leader. + let leaderId: String + let followerId: String + if draggedPlayer?.state == .playing, player.state != .playing { + leaderId = draggedId + followerId = player.playerId + } else { + leaderId = player.playerId + followerId = draggedId + } + syncLogger.debug("Grouping: follower=\(followerId) leader=\(leaderId) draggedState=\(draggedPlayer?.state.rawValue ?? "nil") targetState=\(player.state.rawValue)") + Task { + do { + try await service.playerManager.syncPlayer(playerId: followerId, targetPlayerId: leaderId) + } catch { + let msg = error.localizedDescription + syncLogger.error("syncPlayer failed: \(msg)") + await MainActor.run { + toastManager.show(msg, icon: "exclamationmark.triangle", iconColor: .red) + } + } + } + } + Button("Cancel", role: .cancel) { + pendingDraggedId = nil + } + } message: { + if let draggedId = pendingDraggedId, + let draggedName = service.playerManager.players[draggedId]?.name { + Text("Group \"\(player.name)\" with \"\(draggedName)\"?") + } + } + } + + private var groupConfirmTitle: String { + guard let draggedId = pendingDraggedId, + let draggedName = service.playerManager.players[draggedId]?.name else { + return "Create Group?" + } + return "Group with \(draggedName)?" } } diff --git a/Mobile Music Assistant/ViewsRootView.swift b/Mobile Music Assistant/ViewsRootView.swift index 7be4d12..d89002a 100644 --- a/Mobile Music Assistant/ViewsRootView.swift +++ b/Mobile Music Assistant/ViewsRootView.swift @@ -6,49 +6,157 @@ // import SwiftUI +import UIKit struct RootView: View { @Environment(MAService.self) private var service - + @State private var isInitializing = true - + @State private var loadingProgress: Double = 0.0 + @State private var loadingPhase: String = "Starting…" + var body: some View { Group { if isInitializing { - // Loading screen while checking for saved credentials - VStack(spacing: 20) { - ProgressView() - Text("Connecting...") - .foregroundStyle(.secondary) - } + SplashView(progress: loadingProgress, phase: loadingPhase) + .transition(.opacity) } else if service.isConnected { - // Main app view when connected MainTabView() + .transition(.opacity) } else { - // Login view when not connected LoginView() + .transition(.opacity) } } + .animation(.easeInOut(duration: 0.4), value: isInitializing) + .animation(.easeInOut(duration: 0.4), value: service.isConnected) .applyTheme() .applyLocale() .task { await initializeConnection() } } - + // MARK: - Initialization - + private func initializeConnection() async { - // Try to connect with saved credentials - if service.authManager.isAuthenticated { - do { - try await service.connectWithSavedCredentials() - } catch { - print("Auto-connect failed: \(error.localizedDescription)") + guard service.authManager.isAuthenticated else { + // No saved credentials — skip straight to login + withAnimation { loadingProgress = 1.0 } + try? await Task.sleep(for: .milliseconds(300)) + isInitializing = false + return + } + + // Phase 1: Connect to server + withAnimation { loadingPhase = "Connecting to server…"; loadingProgress = 0.1 } + do { + try await service.connectWithSavedCredentials() + } catch { + print("Auto-connect failed: \(error.localizedDescription)") + withAnimation { loadingProgress = 1.0 } + try? await Task.sleep(for: .milliseconds(300)) + isInitializing = false + return + } + + // Phase 2: Load players + withAnimation { loadingPhase = "Loading players…"; loadingProgress = 0.55 } + try? await service.playerManager.loadPlayers() + + // Done + withAnimation { loadingPhase = "Ready"; loadingProgress = 1.0 } + try? await Task.sleep(for: .milliseconds(400)) + isInitializing = false + } +} + +// MARK: - Splash Screen + +private let splashBackground = Color(red: 0.07, green: 0.09, blue: 0.12) +private let splashTeal = Color(red: 0.0, green: 0.82, blue: 0.75) + +private struct SplashView: View { + let progress: Double + let phase: String + + var body: some View { + ZStack { + splashBackground.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // App icon + if let icon = UIImage(named: "AppIcon") { + Image(uiImage: icon) + .resizable() + .frame(width: 120, height: 120) + .clipShape(RoundedRectangle(cornerRadius: 27)) + .shadow(color: splashTeal.opacity(0.25), radius: 24, y: 8) + } else { + // Fallback: recreate the waveform icon in SwiftUI + WaveformIcon() + .frame(width: 120, height: 120) + } + + Spacer().frame(height: 24) + + // App name + Text("Mobile MA") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.white) + + Text("A client for Music Assistant") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.45)) + + Spacer() + + // Progress section + VStack(spacing: 10) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.1)) + .frame(height: 3) + Capsule() + .fill(splashTeal) + .frame(width: geo.size.width * progress, height: 3) + .animation(.easeInOut(duration: 0.4), value: progress) + } + } + .frame(height: 3) + .padding(.horizontal, 48) + + Text(phase) + .font(.caption) + .foregroundStyle(.white.opacity(0.4)) + .animation(.easeInOut, value: phase) + } + .padding(.bottom, 60) + } + } + } +} + +/// Fallback waveform graphic matching the app icon style. +private struct WaveformIcon: View { + private let barHeights: [CGFloat] = [0.32, 0.55, 0.75, 1.0, 0.78, 0.52, 0.28] + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 27) + .fill(splashBackground) + HStack(alignment: .center, spacing: 5) { + ForEach(Array(barHeights.enumerated()), id: \.offset) { _, h in + Capsule() + .fill(splashTeal.opacity(0.5 + 0.5 * h)) + .frame(width: 10, height: 70 * h) + } } } - - isInitializing = false } } diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift index 1159f18..ed5d1b9 100644 --- a/ViewsPlayerNowPlayingView.swift +++ b/ViewsPlayerNowPlayingView.swift @@ -11,6 +11,12 @@ struct PlayerNowPlayingView: View { @Environment(MAService.self) private var service @Environment(\.dismiss) private var dismiss let playerId: String + /// When this player is a sync member, the leader's ID is used for + /// queue data, album art, track info, and transport controls. + var leaderPlayerId: String? = nil + + /// The ID to use for everything except volume. + private var effectivePlayerId: String { leaderPlayerId ?? playerId } @State private var localVolume: Double = 0 @State private var isVolumeEditing = false @@ -30,12 +36,18 @@ struct PlayerNowPlayingView: View { @State private var showClearConfirm = false // Auto-tracks live updates via @Observable + /// Volume and mute state come from this player (the member itself). private var player: MAPlayer? { service.playerManager.players[playerId] } + /// Queue, track info, and transport state come from the effective player (leader if synced). + private var effectivePlayer: MAPlayer? { + service.playerManager.players[effectivePlayerId] + } + private var playerQueue: MAPlayerQueue? { - service.playerManager.playerQueues[playerId] + service.playerManager.playerQueues[effectivePlayerId] } private var currentItem: MAQueueItem? { @@ -50,25 +62,25 @@ struct PlayerNowPlayingView: View { Double(currentItem?.duration ?? 0) } - // Queue computed properties + // Queue computed properties — all use effectivePlayerId (leader when synced) private var queueItems: [MAQueueItem] { - service.playerManager.queues[playerId] ?? [] + service.playerManager.queues[effectivePlayerId] ?? [] } private var currentQueueIndex: Int? { - service.playerManager.playerQueues[playerId]?.currentIndex + service.playerManager.playerQueues[effectivePlayerId]?.currentIndex } private var currentItemId: String? { - service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId + service.playerManager.playerQueues[effectivePlayerId]?.currentItem?.queueItemId } private var shuffleEnabled: Bool { - service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false + service.playerManager.playerQueues[effectivePlayerId]?.shuffleEnabled ?? false } private var repeatMode: RepeatMode { - service.playerManager.playerQueues[playerId]?.repeatMode ?? .off + service.playerManager.playerQueues[effectivePlayerId]?.repeatMode ?? .off } var body: some View { @@ -148,9 +160,9 @@ struct PlayerNowPlayingView: View { .onChange(of: playerQueue?.elapsedTime) { if !isProgressEditing { syncElapsedTime() } } - .onChange(of: player?.state) { + .onChange(of: effectivePlayer?.state) { syncElapsedTime() - if player?.state == .playing { + if effectivePlayer?.state == .playing { startProgressTimer() } else { progressTimer?.invalidate() @@ -159,12 +171,12 @@ struct PlayerNowPlayingView: View { } .task { isQueueLoading = true - try? await service.playerManager.loadQueue(playerId: playerId) + try? await service.playerManager.loadQueue(playerId: effectivePlayerId) isQueueLoading = false } .confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) { Button("Clear Queue", role: .destructive) { - Task { try? await service.playerManager.clearQueue(playerId: playerId) } + Task { try? await service.playerManager.clearQueue(playerId: effectivePlayerId) } } Button("Cancel", role: .cancel) { } } @@ -196,10 +208,10 @@ struct PlayerNowPlayingView: View { Spacer() VStack(spacing: 2) { - Text("Now Playing") + Text(leaderPlayerId != nil ? player?.name ?? "Now Playing" : "Now Playing") .font(.caption) .foregroundStyle(.secondary) - Text(player?.name ?? "") + Text(effectivePlayer?.name ?? "") .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) @@ -291,10 +303,10 @@ struct PlayerNowPlayingView: View { private var controlsView: some View { VStack(spacing: 16) { // Transport controls - if let player { + if let ep = effectivePlayer { HStack(spacing: 48) { Button { - Task { try? await service.playerManager.previousTrack(playerId: playerId) } + Task { try? await service.playerManager.previousTrack(playerId: effectivePlayerId) } } label: { Image(systemName: "backward.fill") .font(.system(size: 30)) @@ -303,21 +315,21 @@ struct PlayerNowPlayingView: View { Button { Task { - if player.state == .playing { - try? await service.playerManager.pause(playerId: playerId) + if ep.state == .playing { + try? await service.playerManager.pause(playerId: effectivePlayerId) } else { - try? await service.playerManager.play(playerId: playerId) + try? await service.playerManager.play(playerId: effectivePlayerId) } } } label: { - Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill") + Image(systemName: ep.state == .playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) .foregroundStyle(.primary) - .symbolEffect(.bounce, value: player.state == .playing) + .symbolEffect(.bounce, value: ep.state == .playing) } Button { - Task { try? await service.playerManager.nextTrack(playerId: playerId) } + Task { try? await service.playerManager.nextTrack(playerId: effectivePlayerId) } } label: { Image(systemName: "forward.fill") .font(.system(size: 30)) @@ -369,10 +381,7 @@ struct PlayerNowPlayingView: View { .onEnded { value in localVolume = max(0, min(100, value.location.x / geo.size.width * 100)) Task { - try? await service.playerManager.setVolume( - playerId: playerId, - level: Int(localVolume) - ) + try? await setVolumeForPlayer(level: Int(localVolume)) } volumeSettleTask?.cancel() volumeSettleTask = Task { @MainActor in @@ -436,7 +445,7 @@ struct PlayerNowPlayingView: View { displayedElapsed = seekTo Task { do { - try await service.playerManager.seek(playerId: playerId, position: seekTo) + try await service.playerManager.seek(playerId: effectivePlayerId, position: seekTo) } catch { print("❌ Seek failed: \(error)") } @@ -523,7 +532,7 @@ struct PlayerNowPlayingView: View { .onTapGesture { Task { try? await service.playerManager.playIndex( - playerId: playerId, + playerId: effectivePlayerId, index: index ) } @@ -541,7 +550,7 @@ struct PlayerNowPlayingView: View { HStack(spacing: 0) { // Shuffle Button { - Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) } + Task { try? await service.playerManager.setShuffle(playerId: effectivePlayerId, enabled: !shuffleEnabled) } } label: { VStack(spacing: 3) { Image(systemName: "shuffle") @@ -563,7 +572,7 @@ struct PlayerNowPlayingView: View { case .all: next = .one case .one: next = .off } - Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) } + Task { try? await service.playerManager.setRepeatMode(playerId: effectivePlayerId, mode: next) } } label: { VStack(spacing: 3) { Image(systemName: repeatMode == .one ? "repeat.1" : "repeat") @@ -606,7 +615,7 @@ struct PlayerNowPlayingView: View { return } - if player?.state == .playing, + if effectivePlayer?.state == .playing, let lastUpdated = queue.elapsedTimeLastUpdated { let serverNow = Date().timeIntervalSince1970 let delta = serverNow - lastUpdated @@ -618,10 +627,10 @@ struct PlayerNowPlayingView: View { private func startProgressTimer() { progressTimer?.invalidate() - guard player?.state == .playing else { return } + guard effectivePlayer?.state == .playing else { return } progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in Task { @MainActor in - guard player?.state == .playing, !isProgressEditing else { return } + guard effectivePlayer?.state == .playing, !isProgressEditing else { return } displayedElapsed += 0.5 if trackDuration > 0 { displayedElapsed = min(displayedElapsed, trackDuration) @@ -639,11 +648,20 @@ struct PlayerNowPlayingView: View { // MARK: - Volume Helpers + /// Routes to group_volume for group leaders, individual volume otherwise. + private func setVolumeForPlayer(level: Int) async throws { + if player?.isGroupLeader == true { + try await service.playerManager.setGroupVolume(playerId: playerId, level: level) + } else { + try await service.playerManager.setVolume(playerId: playerId, level: level) + } + } + private func adjustVolume(by delta: Int) { let newVolume = max(0, min(100, Int(localVolume) + delta)) localVolume = Double(newVolume) if isMuted && delta > 0 { isMuted = false } - Task { try? await service.playerManager.setVolume(playerId: playerId, level: newVolume) } + Task { try? await setVolumeForPlayer(level: newVolume) } } private func handleMute() { @@ -651,12 +669,12 @@ struct PlayerNowPlayingView: View { let restore = preMuteVolume > 0 ? preMuteVolume : 50 localVolume = restore isMuted = false - Task { try? await service.playerManager.setVolume(playerId: playerId, level: Int(restore)) } + Task { try? await setVolumeForPlayer(level: Int(restore)) } } else { preMuteVolume = localVolume localVolume = 0 isMuted = true - Task { try? await service.playerManager.setVolume(playerId: playerId, level: 0) } + Task { try? await setVolumeForPlayer(level: 0) } } } }