Files
MobileMusicAssistant/Mobile Music Assistant/ViewsMainTabView.swift
T

557 lines
21 KiB
Swift

//
// 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<String> {
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
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) {
service.disconnect()
service.authManager.logout()
} label: {
Label("Disconnect", systemImage: "arrow.right.square")
}
}
// 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("Thank You!", isPresented: $showThankYou) {
Button("You're welcome!", role: .cancel) { }
} message: {
Text("Your support means a lot and helps keep Mobile Music Assistant alive.")
}
.onChange(of: storeManager.purchaseResult) { _, result in
if case .success = result {
showThankYou = true
}
}
}
}
}
#Preview {
MainTabView()
.environment(MAService())
}