Version 1 in App Store
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user