Version 1.5: Groups

This commit is contained in:
2026-04-16 05:48:51 +02:00
parent ae706bc8bc
commit e9462f6d91
8 changed files with 959 additions and 203 deletions
+331 -49
View File
@@ -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)
+29 -7
View File
@@ -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
+123 -1
View File
@@ -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 {
+287 -89
View File
@@ -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)?"
}
}
+128 -20
View File
@@ -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
}
}
+53 -35
View File
@@ -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) }
}
}
}