Version 1.5: Groups
This commit is contained in:
@@ -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" : {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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)?"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user