811 lines
32 KiB
Swift
811 lines
32 KiB
Swift
//
|
|
// MainTabView.swift
|
|
// Mobile Music Assistant
|
|
//
|
|
// Created by Sven Hanold on 26.03.26.
|
|
//
|
|
|
|
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
|
|
@Environment(MAStoreManager.self) private var storeManager
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@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()
|
|
}
|
|
.badge(storeManager.hasEverSupported ? Text("★") : nil)
|
|
}
|
|
.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()
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
Task { try? await service.playerManager.loadPlayers() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 nowPlayingTarget: NowPlayingTarget?
|
|
|
|
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 && !hasContent {
|
|
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 members = leader.groupChilds.compactMap { service.playerManager.players[$0] }
|
|
PlayerGroupRow(
|
|
leader: 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) }
|
|
}
|
|
)
|
|
}
|
|
|
|
// Solo players — drag handle initiates drag, card accepts drops
|
|
ForEach(soloPlayers) { player in
|
|
PlayerRow(player: player) {
|
|
nowPlayingTarget = NowPlayingTarget(playerId: player.playerId, leaderPlayerId: nil)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Players & Groups")
|
|
.withMANavigation()
|
|
.task {
|
|
await loadPlayers()
|
|
}
|
|
.refreshable {
|
|
await loadPlayers()
|
|
}
|
|
.sheet(item: $nowPlayingTarget) { target in
|
|
PlayerNowPlayingView(playerId: target.playerId, leaderPlayerId: target.leaderPlayerId)
|
|
.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
|
|
@Environment(MAToastManager.self) private var toastManager
|
|
let leader: MAPlayer
|
|
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
|
|
}
|
|
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
|
|
|
|
private var groupName: String {
|
|
([leader.name] + members.map(\.name)).joined(separator: " + ")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
// Leader card
|
|
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 { 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)
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) { pendingDraggedId = nil }
|
|
}
|
|
.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)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Player Row
|
|
|
|
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
|
|
}
|
|
|
|
private var mediaItem: MAMediaItem? {
|
|
currentItem?.mediaItem
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// 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)
|
|
|
|
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() }
|
|
// 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,
|
|
!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)?"
|
|
}
|
|
}
|
|
|
|
// 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
|
|
@AppStorage("liveActivityEnabled") private var liveActivityEnabled = true
|
|
|
|
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")
|
|
}
|
|
|
|
// Now Playing Section
|
|
Section {
|
|
Toggle(isOn: $liveActivityEnabled) {
|
|
Label("Lock Screen & Dynamic Island", systemImage: "music.note.list")
|
|
}
|
|
.onChange(of: liveActivityEnabled) { _, enabled in
|
|
if !enabled {
|
|
service.playerManager.liveActivityManager.end()
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Now Playing")
|
|
} footer: {
|
|
Text("Shows the current track on the Lock Screen and in the Dynamic Island.")
|
|
}
|
|
|
|
// 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 {
|
|
// Supporter badge row
|
|
if storeManager.hasEverSupported {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "star.fill")
|
|
.foregroundStyle(.orange)
|
|
Text("Supporter")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(.orange)
|
|
Spacer()
|
|
Text("Thank you! ♥")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
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())
|
|
}
|