Version 1 in App Store

This commit is contained in:
2026-04-05 19:44:05 +02:00
parent f931c92d94
commit c780be089d
12 changed files with 744 additions and 1484 deletions
@@ -7,126 +7,228 @@
import SwiftUI
enum PlayerSelection {
case localPlayer
case remotePlayer(MAPlayer)
}
struct EnhancedPlayerPickerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(MAService.self) private var service
let players: [MAPlayer]
let supportsLocalPlayback: Bool
let onSelect: (PlayerSelection) -> Void
let onSelect: (MAPlayer) -> Void
/// IDs of all players that are sync members (not the leader)
private var syncedMemberIds: Set<String> {
Set(players.flatMap { $0.groupChilds })
}
private var groupLeaders: [MAPlayer] {
players.filter { $0.isGroupLeader }
}
private var soloPlayers: [MAPlayer] {
players.filter { !$0.isGroupLeader && !syncedMemberIds.contains($0.playerId) }
}
var body: some View {
NavigationStack {
List {
// Local iPhone Player
if supportsLocalPlayback {
Section {
Button {
onSelect(.localPlayer)
ScrollView {
VStack(spacing: 12) {
// Group cards at the top
ForEach(groupLeaders) { leader in
let memberNames = leader.groupChilds
.compactMap { service.playerManager.players[$0]?.name }
PickerGroupCard(leader: leader, memberNames: memberNames) {
onSelect(leader)
dismiss()
}
}
// Solo player cards
ForEach(soloPlayers) { player in
PickerPlayerCard(player: player) {
onSelect(player)
dismiss()
} label: {
HStack {
Image(systemName: "iphone")
.foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 4) {
Text("This iPhone")
.font(.headline)
.foregroundStyle(.primary)
Text("Play directly on this device")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
}
} header: {
Text("Local Playback")
}
}
// Remote Players
if !players.isEmpty {
Section {
ForEach(players) { player in
Button {
onSelect(.remotePlayer(player))
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(player.name)
.font(.headline)
.foregroundStyle(.primary)
HStack(spacing: 6) {
Image(systemName: stateIcon(for: player.state))
.foregroundStyle(stateColor(for: player.state))
.font(.caption)
Text(player.state.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.disabled(!player.available)
}
} header: {
Text("Remote Players")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.navigationTitle("Play on...")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
Button("Cancel") { dismiss() }
}
}
}
}
private func stateIcon(for state: PlayerState) -> String {
switch state {
case .playing: return "play.circle.fill"
case .paused: return "pause.circle.fill"
case .idle: return "stop.circle"
case .off: return "power.circle"
}
}
// MARK: - Picker Player Card
private struct PickerPlayerCard: View {
@Environment(MAService.self) private var service
let player: MAPlayer
let onSelect: () -> Void
private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[player.playerId]?.currentItem
}
private func stateColor(for state: PlayerState) -> Color {
switch state {
case .playing: return .green
case .paused: return .orange
case .idle: return .gray
case .off: return .red
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
var body: some View {
HStack(spacing: 12) {
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()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.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 { onSelect() }
}
}
// MARK: - Picker Group Card
private struct PickerGroupCard: View {
@Environment(MAService.self) private var service
let leader: MAPlayer
let memberNames: [String]
let onSelect: () -> 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()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.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 { onSelect() }
}
}
#Preview {
EnhancedPlayerPickerView(
players: [],
supportsLocalPlayback: true,
onSelect: { _ in }
)
.environment(MAService())
}