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
@@ -1,145 +0,0 @@
# Music Assistant Audio Streaming Integration
## Übersicht
Um Audio vom Music Assistant Server auf dem iPhone abzuspielen, müssen wir:
1. Stream-URL vom Server anfordern
2. AVPlayer mit dieser URL konfigurieren
3. Playback-Status zum Server zurückmelden
## Stream-URL erhalten
### API Call: `player_queues/cmd/get_stream_url`
```swift
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result,
let urlString = result.value as? String,
let url = URL(string: urlString) else {
throw ClientError.serverError("Invalid stream URL")
}
return url
}
```
### Beispiel Stream-URL Format
```
http://MA_SERVER:8095/api/stream/<queue_id>/<queue_item_id>
```
## Implementierungsschritte
### 1. Stream-URL in MAService hinzufügen
```swift
// In MAService.swift
func getStreamURL(queueId: String, queueItemId: String) async throws -> URL {
let response = try await webSocketClient.sendCommand(
"player_queues/cmd/get_stream_url",
args: [
"queue_id": queueId,
"queue_item_id": queueItemId
]
)
guard let result = response.result else {
throw MAWebSocketClient.ClientError.serverError("No result")
}
// Try to extract URL from response
if let urlString = result.value as? String,
let url = URL(string: urlString) {
return url
}
throw MAWebSocketClient.ClientError.serverError("Invalid stream URL format")
}
```
### 2. Integration in MAAudioPlayer
```swift
// In MAAudioPlayer.swift
func playQueueItem(_ item: MAQueueItem, queueId: String) async throws {
logger.info("Playing queue item: \(item.name)")
// Get stream URL from server
let streamURL = try await service.getStreamURL(
queueId: queueId,
queueItemId: item.queueItemId
)
// Load and play
loadAndPlay(item: item, streamURL: streamURL)
}
```
### 3. Status-Updates zum Server senden
```swift
// Player-Status synchronisieren
func syncPlayerState() async throws {
try await service.webSocketClient.sendCommand(
"players/cmd/update_state",
args: [
"player_id": "ios_device",
"state": isPlaying ? "playing" : "paused",
"current_time": currentTime,
"volume": Int(volume * 100)
]
)
}
```
## Format-Unterstützung
AVPlayer unterstützt nativ:
- ✅ MP3
- ✅ AAC
- ✅ M4A
- ✅ WAV
- ✅ AIFF
- ✅ HLS Streams
Für FLAC benötigt man:
- ⚠️ Server-seitige Transcoding (MA kann das automatisch)
- 🔧 Oder: Third-party Decoder (z.B. via AudioToolbox)
## Authentifizierung für Stream-URLs
Stream-URLs erfordern möglicherweise den Auth-Token:
```swift
var request = URLRequest(url: streamURL)
if let token = service.authManager.currentToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let playerItem = AVPlayerItem(asset: AVURLAsset(url: streamURL, options: [
"AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]
]))
```
## Nächste Schritte
1. ✅ Implementiere `getStreamURL()` in MAService
2. ✅ Update `MAAudioPlayer.playQueueItem()`
3. ✅ Teste mit verschiedenen Audio-Formaten
4. ✅ Implementiere Player-State-Sync zum Server
5. ✅ Handle Netzwerk-Fehler & Buffering
## Referenzen
- [MA Server API Docs](http://YOUR_SERVER:8095/api-docs)
- [AVPlayer Documentation](https://developer.apple.com/documentation/avfoundation/avplayer)
- [AVAudioSession Best Practices](https://developer.apple.com/documentation/avfaudio/avaudiosession)
@@ -1,20 +0,0 @@
//
// AudioPlayerEnvironment.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
// Environment key for audio player
private struct AudioPlayerKey: EnvironmentKey {
static let defaultValue: MAAudioPlayer? = nil
}
extension EnvironmentValues {
var audioPlayer: MAAudioPlayer? {
get { self[AudioPlayerKey.self] }
set { self[AudioPlayerKey.self] = newValue }
}
}
@@ -0,0 +1,51 @@
//
// MANavigationDestination.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
/// Central navigation destination enum for type-safe navigation throughout the app
enum MANavigationDestination: Hashable {
case artist(MAArtist)
case album(MAAlbum)
case playlist(MAPlaylist)
case player(String) // playerId
}
/// ViewModifier to apply all navigation destinations consistently
struct MANavigationDestinations: ViewModifier {
func body(content: Content) -> some View {
content
.navigationDestination(for: MAArtist.self) { artist in
ArtistDetailView(artist: artist)
}
.navigationDestination(for: MAAlbum.self) { album in
AlbumDetailView(album: album)
}
.navigationDestination(for: MAPlaylist.self) { playlist in
PlaylistDetailView(playlist: playlist)
}
.navigationDestination(for: MANavigationDestination.self) { destination in
switch destination {
case .artist(let artist):
ArtistDetailView(artist: artist)
case .album(let album):
AlbumDetailView(album: album)
case .playlist(let playlist):
PlaylistDetailView(playlist: playlist)
case .player(let playerId):
PlayerView(playerId: playerId)
}
}
}
}
extension View {
/// Apply standard Music Assistant navigation destinations to any view
func withMANavigation() -> some View {
modifier(MANavigationDestinations())
}
}
@@ -1,444 +0,0 @@
//
// MAAudioPlayer.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import Foundation
import AVFoundation
import MediaPlayer
import OSLog
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "AudioPlayer")
/// Audio player for local playback on iPhone
@Observable
final class MAAudioPlayer: NSObject {
// MARK: - Properties
private let service: MAService
private var player: AVPlayer?
private(set) var currentItem: MAQueueItem?
private var timeObserver: Any?
// Playback state
private(set) var isPlaying = false
private(set) var currentTime: TimeInterval = 0
private(set) var duration: TimeInterval = 0
// Volume
var volume: Float {
get { AVAudioSession.sharedInstance().outputVolume }
set {
// Volume can only be changed via system controls on iOS
// This is here for compatibility
}
}
// MARK: - Initialization
init(service: MAService) {
self.service = service
super.init()
setupAudioSession()
setupRemoteCommands()
setupNotifications()
}
deinit {
cleanupPlayer()
NotificationCenter.default.removeObserver(self)
}
// MARK: - Audio Session Setup
private func setupAudioSession() {
let audioSession = AVAudioSession.sharedInstance()
do {
// Configure for playback
try audioSession.setCategory(
.playback,
mode: .default,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.setActive(true)
logger.info("Audio session configured")
} catch {
logger.error("Failed to configure audio session: \(error.localizedDescription)")
}
}
// MARK: - Remote Commands (Lock Screen Controls)
private func setupRemoteCommands() {
let commandCenter = MPRemoteCommandCenter.shared()
// Play command
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
// Pause command
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
// Stop command
commandCenter.stopCommand.addTarget { [weak self] _ in
self?.stop()
return .success
}
// Next track
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
Task {
await self?.nextTrack()
}
return .success
}
// Previous track
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
Task {
await self?.previousTrack()
}
return .success
}
// Change playback position
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
return .commandFailed
}
self?.seek(to: event.positionTime)
return .success
}
logger.info("Remote commands configured")
}
// MARK: - Notifications
private func setupNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance()
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance()
)
}
@objc private func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
return
}
switch type {
case .began:
// Interruption began (e.g., phone call)
pause()
logger.info("Audio interrupted - pausing")
case .ended:
// Interruption ended
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else {
return
}
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
play()
logger.info("Audio interruption ended - resuming")
}
@unknown default:
break
}
}
@objc private func handleRouteChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
return
}
switch reason {
case .oldDeviceUnavailable:
// Headphones unplugged
pause()
logger.info("Audio route changed - pausing")
default:
break
}
}
// MARK: - Playback Control
/// Play current item or resume
func play() {
guard let player else { return }
player.play()
isPlaying = true
updateNowPlayingInfo()
logger.info("Playing")
}
/// Pause playback
func pause() {
guard let player else { return }
player.pause()
isPlaying = false
updateNowPlayingInfo()
logger.info("Paused")
}
/// Stop playback
func stop() {
cleanupPlayer()
isPlaying = false
currentItem = nil
clearNowPlayingInfo()
logger.info("Stopped")
}
/// Next track
func nextTrack() async {
logger.info("Next track requested")
// TODO: Get next item from queue
// For now, just stop
stop()
}
/// Previous track
func previousTrack() async {
logger.info("Previous track requested")
// TODO: Get previous item from queue
// For now, restart current track
seek(to: 0)
}
/// Seek to position
func seek(to time: TimeInterval) {
guard let player else { return }
let cmTime = CMTime(seconds: time, preferredTimescale: 600)
player.seek(to: cmTime) { [weak self] _ in
self?.updateNowPlayingInfo()
}
logger.info("Seeking to \(time)s")
}
// MARK: - Load & Play Media
/// Play a queue item from a specific player's queue
func playQueueItem(_ item: MAQueueItem, queueId: String) async throws {
logger.info("Playing queue item: \(item.name) from queue \(queueId)")
// Get stream URL from server
let streamURL = try await service.getStreamURL(
queueId: queueId,
queueItemId: item.queueItemId
)
logger.info("Got stream URL: \(streamURL.absoluteString)")
// Load and play
await loadAndPlay(item: item, streamURL: streamURL)
}
/// Play a media item by URI (adds to queue and plays)
func playMediaItem(uri: String, queueId: String) async throws {
logger.info("Playing media item: \(uri)")
// First, tell the server to add this to the queue
try await service.playMedia(playerId: queueId, uri: uri)
// Wait a bit for the queue to update
try await Task.sleep(for: .milliseconds(500))
// Get the updated queue
let queue = try await service.getQueue(playerId: queueId)
// Find the item we just added (should be first or currently playing)
guard let item = queue.first else {
throw MAWebSocketClient.ClientError.serverError("Queue is empty after adding item")
}
// Get stream URL and play
try await playQueueItem(item, queueId: queueId)
}
/// Load and play a media item with stream URL
private func loadAndPlay(item: MAQueueItem, streamURL: URL) async {
await MainActor.run {
logger.info("Loading media: \(item.name)")
cleanupPlayer()
currentItem = item
// Build authenticated request if needed
var headers: [String: String] = [:]
if let token = service.authManager.currentToken {
headers["Authorization"] = "Bearer \(token)"
}
// Create asset with auth headers
let asset = AVURLAsset(url: streamURL, options: [
"AVURLAssetHTTPHeaderFieldsKey": headers
])
// Create player item
let playerItem = AVPlayerItem(asset: asset)
// Create player
player = AVPlayer(playerItem: playerItem)
// Observe playback time
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = player?.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] time in
self?.updatePlaybackTime(time)
}
// Observe player status
NotificationCenter.default.addObserver(
self,
selector: #selector(playerDidFinishPlaying),
name: .AVPlayerItemDidPlayToEndTime,
object: playerItem
)
// Get duration (async)
Task {
let duration = try? await asset.load(.duration)
if let duration, duration.seconds.isFinite {
await MainActor.run {
self.duration = duration.seconds
self.updateNowPlayingInfo()
}
}
}
// Start playing
play()
}
}
@objc private func playerDidFinishPlaying() {
logger.info("Player finished playing")
// Auto-play next track
Task {
await nextTrack()
}
}
private func updatePlaybackTime(_ time: CMTime) {
let seconds = time.seconds
guard seconds.isFinite else { return }
currentTime = seconds
updateNowPlayingInfo()
}
private func cleanupPlayer() {
if let timeObserver {
player?.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
player?.pause()
player = nil
currentTime = 0
duration = 0
}
// MARK: - Now Playing Info (Lock Screen)
private func updateNowPlayingInfo() {
guard let item = currentItem else {
clearNowPlayingInfo()
return
}
var nowPlayingInfo: [String: Any] = [:]
// Track info
nowPlayingInfo[MPMediaItemPropertyTitle] = item.name
if let mediaItem = item.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
nowPlayingInfo[MPMediaItemPropertyArtist] = artists.map { $0.name }.joined(separator: ", ")
}
if let album = mediaItem.album {
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album.name
}
}
// Duration & position
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
// Artwork (async load)
if let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl,
let coverURL = service.imageProxyURL(path: imageUrl, size: 512) {
Task {
await loadArtwork(from: coverURL, into: &nowPlayingInfo)
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func loadArtwork(from url: URL, into info: inout [String: Any]) async {
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
await MainActor.run {
var updatedInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
updatedInfo[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedInfo
}
}
} catch {
logger.error("Failed to load artwork: \(error.localizedDescription)")
}
}
private func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
}
@@ -0,0 +1,76 @@
//
// MAThemeManager.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
@Observable
class MAThemeManager {
var colorScheme: AppColorScheme {
didSet {
UserDefaults.standard.set(colorScheme.rawValue, forKey: "appColorScheme")
}
}
init() {
let savedValue = UserDefaults.standard.string(forKey: "appColorScheme") ?? "system"
colorScheme = AppColorScheme(rawValue: savedValue) ?? .system
}
var preferredColorScheme: ColorScheme? {
switch colorScheme {
case .system:
return nil
case .light:
return .light
case .dark:
return .dark
}
}
}
enum AppColorScheme: String, CaseIterable, Identifiable {
case system = "system"
case light = "light"
case dark = "dark"
var id: String { rawValue }
var displayName: String {
switch self {
case .system:
return "System"
case .light:
return "Light"
case .dark:
return "Dark"
}
}
var icon: String {
switch self {
case .system:
return "circle.lefthalf.filled"
case .light:
return "sun.max.fill"
case .dark:
return "moon.fill"
}
}
}
// MARK: - Environment Key
private struct ThemeManagerKey: EnvironmentKey {
static let defaultValue = MAThemeManager()
}
extension EnvironmentValues {
var themeManager: MAThemeManager {
get { self[ThemeManagerKey.self] }
set { self[ThemeManagerKey.self] = newValue }
}
}
@@ -7,13 +7,23 @@
import SwiftUI import SwiftUI
import CryptoKit import CryptoKit
import OSLog
private let imgLogger = Logger(subsystem: "com.musicassistant.mobile", category: "ImageCache")
// MARK: - Image Cache // MARK: - Image Cache
final class ImageCache: @unchecked Sendable { final class ImageCache: @unchecked Sendable {
static let shared = ImageCache() static let shared = ImageCache()
// NSCache: auto-evicts under memory pressure (large buffer)
private let memory = NSCache<NSString, UIImage>() private let memory = NSCache<NSString, UIImage>()
// LRU strong-reference store: last N images stay alive even after NSCache eviction
private var lruImages: [String: UIImage] = [:]
private var lruKeys: [String] = []
private let lruMaxCount = 60
private let lock = NSLock()
private let directory: URL private let directory: URL
private let fileManager = FileManager.default private let fileManager = FileManager.default
@@ -30,23 +40,44 @@ final class ImageCache: @unchecked Sendable {
return hash.map { String(format: "%02x", $0) }.joined() return hash.map { String(format: "%02x", $0) }.joined()
} }
func image(for key: String) -> UIImage? { /// Check in-memory caches (LRU + NSCache). Thread-safe, no disk I/O safe on main thread.
// 1. Memory func memoryImage(for key: String) -> UIImage? {
if let img = memory.object(forKey: key as NSString) { return img } lock.withLock {
// 2. Disk lruImages[key] ?? memory.object(forKey: key as NSString)
}
}
/// Check disk only (no memory). Must be called from a background thread.
func diskImage(for key: String) -> UIImage? {
let file = directory.appendingPathComponent(key) let file = directory.appendingPathComponent(key)
guard let data = try? Data(contentsOf: file), guard let data = try? Data(contentsOf: file),
let img = UIImage(data: data) else { return nil } let img = UIImage(data: data) else { return nil }
memory.setObject(img, forKey: key as NSString, cost: data.count) // Promote to memory on load
storeMemory(img, data: data, for: key)
return img return img
} }
func store(_ image: UIImage, data: Data, for key: String) { func store(_ image: UIImage, data: Data, for key: String) {
memory.setObject(image, forKey: key as NSString, cost: data.count) storeMemory(image, data: data, for: key)
let file = directory.appendingPathComponent(key) let file = directory.appendingPathComponent(key)
try? data.write(to: file, options: .atomic) try? data.write(to: file, options: .atomic)
} }
private func storeMemory(_ image: UIImage, data: Data, for key: String) {
lock.withLock {
memory.setObject(image, forKey: key as NSString, cost: data.count)
// Update LRU: move to most-recent position
lruKeys.removeAll { $0 == key }
lruKeys.append(key)
lruImages[key] = image
// Trim oldest entry when over limit
while lruKeys.count > lruMaxCount {
let oldest = lruKeys.removeFirst()
lruImages.removeValue(forKey: oldest)
}
}
}
/// Total bytes currently stored on disk. /// Total bytes currently stored on disk.
var diskUsageBytes: Int { var diskUsageBytes: Int {
guard let enumerator = fileManager.enumerator( guard let enumerator = fileManager.enumerator(
@@ -63,7 +94,11 @@ final class ImageCache: @unchecked Sendable {
/// Remove all cached artwork from disk and memory. /// Remove all cached artwork from disk and memory.
func clearAll() { func clearAll() {
memory.removeAllObjects() lock.withLock {
memory.removeAllObjects()
lruImages.removeAll()
lruKeys.removeAll()
}
try? fileManager.removeItem(at: directory) try? fileManager.removeItem(at: directory)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
} }
@@ -71,7 +106,7 @@ final class ImageCache: @unchecked Sendable {
// MARK: - CachedAsyncImage // MARK: - CachedAsyncImage
/// Async image view that caches to both memory (NSCache) and disk (FileManager). /// Async image view that caches to memory (NSCache + LRU) and disk.
/// Sends the MA auth token in the Authorization header so the image proxy responds. /// Sends the MA auth token in the Authorization header so the image proxy responds.
struct CachedAsyncImage<Content: View, Placeholder: View>: View { struct CachedAsyncImage<Content: View, Placeholder: View>: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@@ -90,6 +125,12 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
self.url = url self.url = url
self.content = content self.content = content
self.placeholder = placeholder self.placeholder = placeholder
// Pre-warm from memory (LRU + NSCache) thread-safe, no disk I/O.
// Prevents placeholder flash when navigating back to a previously loaded view.
let cached = url.flatMap { u -> UIImage? in
ImageCache.shared.memoryImage(for: ImageCache.shared.cacheKey(for: u))
}
_image = State(initialValue: cached)
} }
var body: some View { var body: some View {
@@ -108,16 +149,26 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
private func loadImage() async { private func loadImage() async {
guard let url else { return } guard let url else { return }
let key = ImageCache.shared.cacheKey(for: url) let key = ImageCache.shared.cacheKey(for: url)
// Serve from cache instantly if available // 1. Memory (LRU + NSCache) instant, no I/O
if let cached = ImageCache.shared.image(for: key) { if let cached = ImageCache.shared.memoryImage(for: key) {
image = cached image = cached
return return
} }
// Build request with auth header // 2. Disk read off the main thread to avoid UI blocking
let diskCached = await Task.detached(priority: .userInitiated) {
ImageCache.shared.diskImage(for: key)
}.value
if let diskCached {
guard !Task.isCancelled else { return }
image = diskCached
return
}
// 3. Network fetch with auth header
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
if let token = service.authManager.currentToken { if let token = service.authManager.currentToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@@ -125,13 +176,16 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
// Only cache successful responses guard !Task.isCancelled else { return }
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return } let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard status == 200 else { return }
guard let uiImage = UIImage(data: data) else { return } guard let uiImage = UIImage(data: data) else { return }
ImageCache.shared.store(uiImage, data: data, for: key) ImageCache.shared.store(uiImage, data: data, for: key)
image = uiImage image = uiImage
} catch { } catch {
// Network errors are silent placeholder stays visible // URLError.cancelled is expected when lazy views leave the viewport not a real error
guard (error as? URLError)?.code != .cancelled else { return }
imgLogger.debug("Image load failed: \(error.localizedDescription) for \(url.absoluteString)")
} }
} }
} }
@@ -7,126 +7,228 @@
import SwiftUI import SwiftUI
enum PlayerSelection {
case localPlayer
case remotePlayer(MAPlayer)
}
struct EnhancedPlayerPickerView: View { struct EnhancedPlayerPickerView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(MAService.self) private var service
let players: [MAPlayer] let players: [MAPlayer]
let supportsLocalPlayback: Bool let onSelect: (MAPlayer) -> Void
let onSelect: (PlayerSelection) -> 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 { var body: some View {
NavigationStack { NavigationStack {
List { ScrollView {
// Local iPhone Player VStack(spacing: 12) {
if supportsLocalPlayback { // Group cards at the top
Section { ForEach(groupLeaders) { leader in
Button { let memberNames = leader.groupChilds
onSelect(.localPlayer) .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() 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...") .navigationTitle("Play on...")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button("Cancel") { dismiss() }
dismiss()
}
} }
} }
} }
} }
}
private func stateIcon(for state: PlayerState) -> String {
switch state { // MARK: - Picker Player Card
case .playing: return "play.circle.fill"
case .paused: return "pause.circle.fill" private struct PickerPlayerCard: View {
case .idle: return "stop.circle" @Environment(MAService.self) private var service
case .off: return "power.circle" let player: MAPlayer
} let onSelect: () -> Void
private var currentItem: MAQueueItem? {
service.playerManager.playerQueues[player.playerId]?.currentItem
} }
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
private func stateColor(for state: PlayerState) -> Color {
switch state { var body: some View {
case .playing: return .green HStack(spacing: 12) {
case .paused: return .orange VStack(alignment: .leading, spacing: 4) {
case .idle: return .gray HStack(spacing: 6) {
case .off: return .red 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 { #Preview {
EnhancedPlayerPickerView( EnhancedPlayerPickerView(
players: [], players: [],
supportsLocalPlayback: true,
onSelect: { _ in } onSelect: { _ in }
) )
.environment(MAService())
} }
@@ -1,103 +0,0 @@
//
// MiniPlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct MiniPlayerView: View {
@Environment(MAService.self) private var service
let audioPlayer: MAAudioPlayer
@Binding var isExpanded: Bool
var body: some View {
HStack(spacing: 12) {
// Album Art Thumbnail
if let item = audioPlayer.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 128)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
.font(.caption)
}
}
// Track Info
VStack(alignment: .leading, spacing: 4) {
if let item = audioPlayer.currentItem {
Text(item.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if let mediaItem = item.mediaItem,
let artists = mediaItem.artists,
!artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} else {
Text("No Track")
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
}
}
Spacer()
// Play/Pause Button
Button {
if audioPlayer.isPlaying {
audioPlayer.pause()
} else {
audioPlayer.play()
}
} label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.title3)
.foregroundStyle(.primary)
}
.padding(.trailing, 8)
}
.padding(12)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 5)
.padding(.horizontal)
.padding(.bottom, 8)
.contentShape(Rectangle())
.onTapGesture {
isExpanded = true
}
}
}
#Preview {
MiniPlayerView(
audioPlayer: MAAudioPlayer(service: MAService()),
isExpanded: .constant(false)
)
.environment(MAService())
}
@@ -6,55 +6,98 @@
// //
import SwiftUI import SwiftUI
import UIKit
struct ArtistsView: View { struct ArtistsView: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
@State private var errorMessage: String? @State private var errorMessage: String?
@State private var showError = false @State private var showError = false
private var artists: [MAArtist] { private var artists: [MAArtist] {
service.libraryManager.artists service.libraryManager.artists
} }
private var isLoading: Bool { private var isLoading: Bool {
service.libraryManager.isLoadingArtists service.libraryManager.isLoadingArtists
} }
private let columns = [ private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 16) GridItem(.adaptive(minimum: 80), spacing: 8)
] ]
/// Artists grouped by first letter; non-alphabetic names go under "#"
private var artistsByLetter: [(String, [MAArtist])] {
let grouped = Dictionary(grouping: artists) { artist -> String in
let first = artist.name.prefix(1).uppercased()
return first.first?.isLetter == true ? String(first) : "#"
}
return grouped.sorted {
if $0.key == "#" { return false }
if $1.key == "#" { return true }
return $0.key < $1.key
}
}
private var availableLetters: [String] {
artistsByLetter.map { $0.0 }
}
var body: some View { var body: some View {
ScrollView { ScrollViewReader { proxy in
LazyVGrid(columns: columns, spacing: 16) { ZStack(alignment: .trailing) {
ForEach(artists) { artist in ScrollView {
NavigationLink(value: artist) { LazyVStack(alignment: .leading, spacing: 0) {
ArtistGridItem(artist: artist) ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
} // Section header
.buttonStyle(.plain) Text(letter)
.task { .font(.headline)
await loadMoreIfNeeded(currentItem: artist) .fontWeight(.bold)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 4)
.id(letter)
// Grid of artists in this section
LazyVGrid(columns: columns, spacing: 8) {
ForEach(letterArtists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: artist)
}
}
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
if isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
}
} }
// Right padding leaves room for the alphabet index
.padding(.trailing, 24)
} }
if isLoading { // Floating alphabet index on the right edge
ProgressView() if !availableLetters.isEmpty {
.gridCellColumns(columns.count) AlphabetIndexView(letters: availableLetters) { letter in
.padding() proxy.scrollTo(letter, anchor: .top)
}
.padding(.trailing, 2)
} }
} }
.padding()
}
.navigationDestination(for: MAArtist.self) { artist in
ArtistDetailView(artist: artist)
} }
.refreshable { .refreshable {
await loadArtists(refresh: true) await loadArtists(refresh: true)
} }
.task { .task {
if artists.isEmpty { await loadArtists(refresh: !artists.isEmpty)
await loadArtists(refresh: false)
}
} }
.alert("Error", isPresented: $showError) { .alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
@@ -73,7 +116,7 @@ struct ArtistsView: View {
} }
} }
} }
private func loadArtists(refresh: Bool) async { private func loadArtists(refresh: Bool) async {
do { do {
try await service.libraryManager.loadArtists(refresh: refresh) try await service.libraryManager.loadArtists(refresh: refresh)
@@ -82,7 +125,7 @@ struct ArtistsView: View {
showError = true showError = true
} }
} }
private func loadMoreIfNeeded(currentItem: MAArtist) async { private func loadMoreIfNeeded(currentItem: MAArtist) async {
do { do {
try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem) try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem)
@@ -93,44 +136,85 @@ struct ArtistsView: View {
} }
} }
// MARK: - Alphabet Index
struct AlphabetIndexView: View {
let letters: [String]
let onSelect: (String) -> Void
@State private var activeLetter: String?
var body: some View {
GeometryReader { geometry in
let itemHeight = geometry.size.height / CGFloat(letters.count)
ZStack {
// Touch-responsive column
VStack(spacing: 0) {
ForEach(letters, id: \.self) { letter in
Text(letter)
.font(.system(size: 11, weight: .bold))
.frame(width: 20, height: itemHeight)
.foregroundStyle(activeLetter == letter ? .white : .accentColor)
.background {
if activeLetter == letter {
Circle()
.fill(Color.accentColor)
.frame(width: 18, height: 18)
}
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let index = Int(value.location.y / itemHeight)
let clamped = max(0, min(letters.count - 1, index))
let letter = letters[clamped]
if activeLetter != letter {
activeLetter = letter
onSelect(letter)
UISelectionFeedbackGenerator().selectionChanged()
}
}
.onEnded { _ in
activeLetter = nil
}
)
}
}
.frame(width: 20)
}
}
// MARK: - Artist Grid Item // MARK: - Artist Grid Item
struct ArtistGridItem: View { struct ArtistGridItem: View {
@Environment(MAService.self) private var service @Environment(MAService.self) private var service
let artist: MAArtist let artist: MAArtist
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 4) {
// Artist Image CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 128)) { image in
if let imageUrl = artist.imageUrl { image
let coverURL = service.imageProxyURL(path: imageUrl, size: 256) .resizable()
.aspectRatio(contentMode: .fill)
CachedAsyncImage(url: coverURL) { image in } placeholder: {
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 160, height: 160)
.clipShape(Circle())
} else {
Circle() Circle()
.fill(Color.gray.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(width: 160, height: 160)
.overlay { .overlay {
Image(systemName: "music.mic") Image(systemName: "music.mic")
.font(.system(size: 40)) .font(.system(size: 22))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.frame(width: 76, height: 76)
// Artist Name .clipShape(Circle())
Text(artist.name) Text(artist.name)
.font(.subheadline) .font(.caption)
.fontWeight(.medium) .fontWeight(.medium)
.lineLimit(2) .lineLimit(1)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
@@ -1,233 +0,0 @@
//
// LocalPlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct LocalPlayerView: View {
@Environment(MAService.self) private var service
@Environment(\.audioPlayer) private var audioPlayer
var body: some View {
NavigationStack {
VStack(spacing: 24) {
if let player = audioPlayer {
// Now Playing Section
nowPlayingSection(player: player)
// Progress Bar
progressBar(player: player)
// Transport Controls
transportControls(player: player)
// Volume Control
volumeControl(player: player)
} else {
ContentUnavailableView(
"No Active Playback",
systemImage: "play.circle",
description: Text("Play something from your library to see controls here")
)
}
Spacer()
}
.padding()
.navigationTitle("Now Playing")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Now Playing Section
@ViewBuilder
private func nowPlayingSection(player: MAAudioPlayer) -> some View {
VStack(spacing: 16) {
// Album Art
if let item = player.currentItem,
let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay {
ProgressView()
}
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(width: 300, height: 300)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
.shadow(radius: 10)
}
// Track Info
VStack(spacing: 8) {
if let item = player.currentItem {
Text(item.name)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
if let mediaItem = item.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if let album = mediaItem.album {
Text(album.name)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
} else {
Text("No Track Playing")
.font(.title2)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
.padding(.top)
}
// MARK: - Progress Bar
@ViewBuilder
private func progressBar(player: MAAudioPlayer) -> some View {
VStack(spacing: 8) {
// Progress slider
Slider(
value: Binding(
get: { player.currentTime },
set: { player.seek(to: $0) }
),
in: 0...max(1, player.duration)
)
// Time labels
HStack {
Text(formatTime(player.currentTime))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(formatTime(player.duration))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
// MARK: - Transport Controls
@ViewBuilder
private func transportControls(player: MAAudioPlayer) -> some View {
HStack(spacing: 40) {
// Previous
Button {
Task {
await player.previousTrack()
}
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
// Play/Pause
Button {
if player.isPlaying {
player.pause()
} else {
player.play()
}
} label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.primary)
}
// Next
Button {
Task {
await player.nextTrack()
}
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
.padding()
}
// MARK: - Volume Control
@ViewBuilder
private func volumeControl(player: MAAudioPlayer) -> some View {
VStack(spacing: 12) {
HStack {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
// System volume - read-only on iOS
Slider(
value: Binding(
get: { Double(player.volume) },
set: { _ in }
),
in: 0...1
)
.disabled(true)
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
Text("Use device volume buttons")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
}
// MARK: - Helpers
private func formatTime(_ seconds: TimeInterval) -> String {
guard seconds.isFinite else { return "0:00" }
let minutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
#Preview {
LocalPlayerView()
.environment(MAService())
}
@@ -1,379 +0,0 @@
//
// PlayerView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct PlayerView: View {
@Environment(MAService.self) private var service
let playerId: String
@State private var player: MAPlayer?
@State private var queueItems: [MAQueueItem] = []
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(spacing: 24) {
if let player {
// Now Playing Section
nowPlayingSection(player: player)
// Transport Controls
transportControls(player: player)
// Volume Control
volumeControl(player: player)
Divider()
.padding(.vertical, 8)
// Queue Section
queueSection
} else if isLoading {
ProgressView()
.padding()
} else if let errorMessage {
ContentUnavailableView(
"Error",
systemImage: "exclamationmark.triangle",
description: Text(errorMessage)
)
}
}
.padding()
}
.navigationTitle(player?.name ?? "Player")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadPlayerData()
observePlayerUpdates()
}
.refreshable {
await loadPlayerData()
}
}
// MARK: - Now Playing Section
@ViewBuilder
private func nowPlayingSection(player: MAPlayer) -> some View {
VStack(spacing: 16) {
// Album Art
if let currentItem = player.currentItem,
let mediaItem = currentItem.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 512)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 10)
} else {
Rectangle()
.fill(Color.gray.opacity(0.2))
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 60))
.foregroundStyle(.secondary)
}
}
// Track Info
VStack(spacing: 8) {
if let currentItem = player.currentItem {
Text(currentItem.name)
.font(.title2)
.fontWeight(.semibold)
.multilineTextAlignment(.center)
if let mediaItem = currentItem.mediaItem {
if let artists = mediaItem.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
if let album = mediaItem.album {
Text(album.name)
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
}
} else {
Text("No Track Playing")
.font(.title3)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
}
}
// MARK: - Transport Controls
@ViewBuilder
private func transportControls(player: MAPlayer) -> some View {
HStack(spacing: 40) {
// Previous
Button {
Task {
try? await service.playerManager.previousTrack(playerId: playerId)
}
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
.disabled(!player.available)
// Play/Pause
Button {
Task {
if player.state == .playing {
try? await service.playerManager.pause(playerId: playerId)
} else {
try? await service.playerManager.play(playerId: playerId)
}
}
} label: {
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.primary)
}
.disabled(!player.available)
// Next
Button {
Task {
try? await service.playerManager.nextTrack(playerId: playerId)
}
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
.disabled(!player.available)
}
.padding()
}
// MARK: - Volume Control
@ViewBuilder
private func volumeControl(player: MAPlayer) -> some View {
VStack(spacing: 12) {
HStack {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(
value: Binding(
get: { Double(player.volume) },
set: { newValue in
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(newValue)
)
}
}
),
in: 0...100,
step: 1
)
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
Text("\(player.volume)%")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.disabled(!player.available)
}
// MARK: - Queue Section
@ViewBuilder
private var queueSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Queue")
.font(.headline)
.padding(.horizontal)
if queueItems.isEmpty {
Text("Queue is empty")
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding()
} else {
LazyVStack(spacing: 0) {
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
QueueItemRow(item: item, index: index)
.contentShape(Rectangle())
.onTapGesture {
Task {
try? await service.playerManager.playIndex(
playerId: playerId,
index: index
)
}
}
if index < queueItems.count - 1 {
Divider()
.padding(.leading, 60)
}
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
}
}
// MARK: - Data Loading
private func loadPlayerData() async {
isLoading = true
errorMessage = nil
do {
// Load player info
let players = try await service.getPlayers()
player = players.first { $0.playerId == playerId }
// Load queue
let items = try await service.getQueue(playerId: playerId)
queueItems = items
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
private func observePlayerUpdates() {
// Observe player updates from PlayerManager
Task {
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(100))
// Update from PlayerManager cache
if let updatedPlayer = service.playerManager.players[playerId] {
await MainActor.run {
player = updatedPlayer
}
}
if let updatedQueue = service.playerManager.queues[playerId] {
await MainActor.run {
queueItems = updatedQueue
}
}
}
}
}
}
// MARK: - Queue Item Row
struct QueueItemRow: View {
@Environment(MAService.self) private var service
let item: MAQueueItem
let index: Int
var body: some View {
HStack(spacing: 12) {
// Thumbnail
if let mediaItem = item.mediaItem,
let imageUrl = mediaItem.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 64)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.frame(width: 48, height: 48)
.overlay {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
// Track Info
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.body)
.lineLimit(1)
if let mediaItem = item.mediaItem,
let artists = mediaItem.artists,
!artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
// Duration
if let duration = item.duration {
Text(formatDuration(duration))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
.padding(.horizontal)
}
private func formatDuration(_ seconds: Int) -> String {
let minutes = seconds / 60
let remainingSeconds = seconds % 60
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
#Preview {
NavigationStack {
PlayerView(playerId: "test_player")
.environment(MAService())
}
}
+217
View File
@@ -0,0 +1,217 @@
//
// PlayerNowPlayingView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 05.04.26.
//
import SwiftUI
struct PlayerNowPlayingView: View {
@Environment(MAService.self) private var service
@Environment(\.dismiss) private var dismiss
let playerId: String
// Auto-tracks live updates via @Observable
private var player: MAPlayer? {
service.playerManager.players[playerId]
}
private var mediaItem: MAMediaItem? {
player?.currentItem?.mediaItem
}
var body: some View {
ZStack {
// Blurred artwork background
CachedAsyncImage(url: service.imageProxyURL(
path: mediaItem?.imageUrl,
provider: mediaItem?.imageProvider,
size: 64
)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Color.clear
}
.ignoresSafeArea()
.blur(radius: 80)
.scaleEffect(1.4)
.opacity(0.5)
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
// Drag indicator
Capsule()
.fill(.secondary.opacity(0.4))
.frame(width: 36, height: 4)
.padding(.top, 12)
.padding(.bottom, 8)
// Player name
HStack {
Button { dismiss() } label: {
Image(systemName: "chevron.down")
.font(.title3)
.fontWeight(.semibold)
.foregroundStyle(.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
Spacer()
VStack(spacing: 2) {
Text("Now Playing")
.font(.caption)
.foregroundStyle(.secondary)
Text(player?.name ?? "")
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
}
Spacer()
// Balance chevron button
Color.clear
.frame(width: 44, height: 44)
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
// Album art
GeometryReader { geo in
let size = min(geo.size.width - 64, geo.size.height)
CachedAsyncImage(url: service.imageProxyURL(
path: mediaItem?.imageUrl,
provider: mediaItem?.imageProvider,
size: 512
)) { image in
image
.resizable()
.aspectRatio(1, contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 16)
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: "music.note")
.font(.system(size: 56))
.foregroundStyle(.secondary)
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.35), radius: 24, y: 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, 32)
.padding(.vertical, 24)
// Track info
VStack(spacing: 6) {
Text(player?.currentItem?.name ?? "")
.font(.title2)
.fontWeight(.bold)
.lineLimit(2)
.multilineTextAlignment(.center)
if let artists = mediaItem?.artists, !artists.isEmpty {
Text(artists.map { $0.name }.joined(separator: ", "))
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(1)
} else if let album = mediaItem?.album {
Text(album.name)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 32)
Spacer(minLength: 24)
// Transport controls
if let player {
HStack(spacing: 48) {
Button {
Task { try? await service.playerManager.previousTrack(playerId: playerId) }
} label: {
Image(systemName: "backward.fill")
.font(.system(size: 30))
.foregroundStyle(.primary)
}
Button {
Task {
if player.state == .playing {
try? await service.playerManager.pause(playerId: playerId)
} else {
try? await service.playerManager.play(playerId: playerId)
}
}
} label: {
Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
.symbolEffect(.bounce, value: player.state == .playing)
}
Button {
Task { try? await service.playerManager.nextTrack(playerId: playerId) }
} label: {
Image(systemName: "forward.fill")
.font(.system(size: 30))
.foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
Spacer(minLength: 24)
// Volume
if let volume = player?.volume {
HStack(spacing: 10) {
Image(systemName: "speaker.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 20)
Slider(
value: Binding(
get: { Double(volume) },
set: { newValue in
Task {
try? await service.playerManager.setVolume(
playerId: playerId,
level: Int(newValue)
)
}
}
),
in: 0...100,
step: 1
)
Image(systemName: "speaker.wave.3.fill")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 20)
}
.padding(.horizontal, 32)
}
Spacer(minLength: 32)
}
}
.presentationDetents([.large])
.presentationDragIndicator(.hidden) // using custom indicator
}
}