// // MainTabView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI import StoreKit struct MainTabView: View { @Environment(MAService.self) private var service @State private var selectedTab: String = "library" var body: some View { TabView(selection: $selectedTab) { Tab("Library", systemImage: "music.note.list", value: "library") { LibraryView() } Tab("Favorites", systemImage: "heart.fill", value: "favorites") { FavoritesView() } Tab("Search", systemImage: "magnifyingglass", value: "search") { NavigationStack { SearchView() .withMANavigation() } } Tab("Players", systemImage: "speaker.wave.2.fill", value: "players") { PlayerListView() } Tab("Settings", systemImage: "gear", value: "settings") { SettingsView() } } .withToast() .task { // Start listening to player events and load players when main view appears service.playerManager.startListening() try? await service.playerManager.loadPlayers() } .onDisappear { service.playerManager.stopListening() } } } // MARK: - Placeholder Views (to be implemented in Phase 2+) struct PlayerListView: View { @Environment(MAService.self) private var service @State private var isLoading = false @State private var errorMessage: String? @State private var nowPlayingPlayer: MAPlayer? private var allPlayers: [MAPlayer] { Array(service.playerManager.players.values) .filter { $0.available } .sorted { $0.name < $1.name } } /// IDs of all players that are sync members (not the leader) private var syncedMemberIds: Set { Set(allPlayers.flatMap { $0.groupChilds }) } /// Players that are sync group leaders (shown as group cards at the top) private var groupLeaders: [MAPlayer] { allPlayers.filter { $0.isGroupLeader } } /// Players that are neither a group leader nor a member of any group private var soloPlayers: [MAPlayer] { allPlayers.filter { !$0.isGroupLeader && !syncedMemberIds.contains($0.playerId) } } private var hasContent: Bool { !allPlayers.isEmpty } var body: some View { NavigationStack { Group { if isLoading { ProgressView() } else if let errorMessage { ContentUnavailableView( "Error Loading Players", systemImage: "exclamationmark.triangle", description: Text(errorMessage) ) } else if !hasContent { ContentUnavailableView( "No Players Found", systemImage: "speaker.slash", description: Text("Make sure your Music Assistant server has configured players") ) } else { ScrollView { // VStack (not Lazy) ensures all drop targets are always rendered VStack(spacing: 12) { // Groups shown at the top ForEach(groupLeaders) { leader in let memberNames = leader.groupChilds .compactMap { service.playerManager.players[$0]?.name } PlayerGroupRow( leader: leader, memberNames: memberNames, onTap: { nowPlayingPlayer = leader }, onDissolve: { Task { try? await service.playerManager.unsyncPlayer(playerId: leader.playerId) } } ) } // Solo players — drag handle initiates drag, card accepts drops ForEach(soloPlayers) { player in PlayerRow(player: player) { nowPlayingPlayer = player } } } .padding(.horizontal, 16) .padding(.vertical, 8) } } } .navigationTitle("Players") .withMANavigation() .task { await loadPlayers() } .refreshable { await loadPlayers() } .sheet(item: $nowPlayingPlayer) { selectedPlayer in PlayerNowPlayingView(playerId: selectedPlayer.playerId) .environment(service) } } } private func loadPlayers() async { isLoading = true errorMessage = nil do { try await service.playerManager.loadPlayers() } catch { errorMessage = error.localizedDescription } isLoading = false } } // MARK: - Player Group Row struct PlayerGroupRow: View { @Environment(MAService.self) private var service let leader: MAPlayer let memberNames: [String] let onTap: () -> Void let onDissolve: () -> Void private var currentItem: MAQueueItem? { service.playerManager.playerQueues[leader.playerId]?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } private var groupName: String { ([leader.name] + memberNames).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") .font(.caption) .foregroundStyle(.green) } 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) .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(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 } .blur(radius: 20) .scaleEffect(1.1) .clipped() Rectangle().fill(.ultraThinMaterial) } } .clipShape(RoundedRectangle(cornerRadius: 16)) .contentShape(RoundedRectangle(cornerRadius: 16)) .onTapGesture { onTap() } } } // MARK: - Player Row struct PlayerRow: View { @Environment(MAService.self) private var service let player: MAPlayer let onTap: () -> Void @State private var isDropTarget = false private var currentItem: MAQueueItem? { service.playerManager.playerQueues[player.playerId]?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } 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. 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) { if player.state == .playing { Image(systemName: "waveform") .font(.caption) .foregroundStyle(.green) } Text(player.name) .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) .foregroundStyle(.tertiary) .lineLimit(1) } } else { Text(player.state == .off ? "Powered Off" : "No Track Playing") .font(.subheadline) .foregroundStyle(.tertiary) .lineLimit(1) } } Spacer() Button { Task { if player.state == .playing { try? await service.playerManager.pause(playerId: player.playerId) } else { try? await service.playerManager.play(playerId: player.playerId) } } } label: { Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 36)) .foregroundStyle(player.state == .playing ? .green : .secondary) .symbolEffect(.bounce, value: player.state == .playing) } .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) } } .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() } // The full card is the drop target — drop a player from another card's handle here .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) } return true } isTargeted: { targeted in isDropTarget = targeted } } } // Removed - Now using dedicated LibraryView.swift file struct SettingsView: View { @Environment(MAService.self) private var service @Environment(\.themeManager) private var themeManager @Environment(\.localeManager) private var localeManager @Environment(MAStoreManager.self) private var storeManager @State private var showThankYou = false @State private var showClearCacheConfirm = false var body: some View { NavigationStack { Form { // Appearance Section Section { ForEach(AppColorScheme.allCases) { scheme in Button { withAnimation(.easeInOut(duration: 0.3)) { themeManager.colorScheme = scheme } } label: { HStack { Image(systemName: scheme.icon) .font(.title2) .foregroundStyle(themeManager.colorScheme == scheme ? .blue : .secondary) .frame(width: 32) VStack(alignment: .leading, spacing: 4) { Text(scheme.displayName) .font(.body) .foregroundStyle(.primary) Text(scheme.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() if themeManager.colorScheme == scheme { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.blue) .font(.title3) } } .padding(.vertical, 4) } .buttonStyle(.plain) } } header: { Text("Appearance") } footer: { Text("Choose how the app looks. System follows your device settings.") } // Language Section Section { Picker("Language", selection: Binding( get: { localeManager.selectedLanguageCode ?? "system" }, set: { localeManager.selectedLanguageCode = $0 == "system" ? nil : $0 } )) { Text("System").tag("system") ForEach(SupportedLanguage.allCases) { lang in Text(verbatim: lang.endonym).tag(lang.rawValue) } } .pickerStyle(.menu) } header: { Text("Language") } footer: { Text("Choose the app language. System uses your device language.") } // Connection Section Section { if let serverURL = service.authManager.serverURL { LabeledContent("Server", value: serverURL.absoluteString) } LabeledContent("Status") { HStack { Circle() .fill(service.isConnected ? .green : .red) .frame(width: 8, height: 8) Text(service.isConnected ? "Connected" : "Disconnected") } } } header: { Text("Connection") } // Actions Section Section { Button(role: .destructive) { showClearCacheConfirm = true } label: { Label("Clear Cache", systemImage: "trash") } Button(role: .destructive) { service.disconnect() service.authManager.logout() } label: { Label("Disconnect", systemImage: "arrow.right.square") } } footer: { Text("Clearing the cache removes all locally stored artwork and library data. The next launch or reload may take longer.") } // Support Development Section Section { if let loadError = storeManager.loadError { Label(loadError, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) } ForEach(storeManager.products, id: \.id) { product in HStack(spacing: 12) { Image(systemName: storeManager.iconName(for: product)) .font(.title2) .foregroundStyle(.orange) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(storeManager.tierName(for: product)) .font(.body) .foregroundStyle(.primary) Text(product.displayPrice) .font(.caption) .foregroundStyle(.secondary) } Spacer() Button { Task { await storeManager.purchase(product) } } label: { Text(product.displayPrice) .font(.subheadline.weight(.medium)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .clipShape(Capsule()) } .buttonStyle(.plain) .disabled(storeManager.isPurchasing) } .padding(.vertical, 4) } } header: { Text("Support Development") } footer: { Text("Do you find this app useful? Support the development by buying the developer a virtual record.") } } .navigationTitle("Settings") .task { await storeManager.loadProducts() } .alert("Clear Cache?", isPresented: $showClearCacheConfirm) { Button("Clear", role: .destructive) { ImageCache.shared.clearAll() service.libraryManager.clearAll() } Button("Cancel", role: .cancel) { } } message: { Text("This will delete all locally cached artwork and library data. The next launch or reload may take longer while everything is fetched again.") } .alert("Thank You!", isPresented: $showThankYou) { Button("You're welcome!", role: .cancel) { } } message: { Text("Your support means a lot and helps keep Mobile MA alive.") } .onChange(of: storeManager.purchaseResult) { _, result in if case .success = result { showThankYou = true } } } } } #Preview { MainTabView() .environment(MAService()) }