Version 1 in App Store
This commit is contained in:
@@ -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() {
|
||||||
|
lock.withLock {
|
||||||
memory.removeAllObjects()
|
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()
|
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
|
// Solo player cards
|
||||||
if !players.isEmpty {
|
ForEach(soloPlayers) { player in
|
||||||
Section {
|
PickerPlayerCard(player: player) {
|
||||||
ForEach(players) { player in
|
onSelect(player)
|
||||||
Button {
|
|
||||||
onSelect(.remotePlayer(player))
|
|
||||||
dismiss()
|
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 {
|
// MARK: - Picker Player Card
|
||||||
switch state {
|
|
||||||
case .playing: return "play.circle.fill"
|
private struct PickerPlayerCard: View {
|
||||||
case .paused: return "pause.circle.fill"
|
@Environment(MAService.self) private var service
|
||||||
case .idle: return "stop.circle"
|
let player: MAPlayer
|
||||||
case .off: return "power.circle"
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
private var currentItem: MAQueueItem? {
|
||||||
|
service.playerManager.playerQueues[player.playerId]?.currentItem
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stateColor(for state: PlayerState) -> Color {
|
Spacer()
|
||||||
switch state {
|
|
||||||
case .playing: return .green
|
Image(systemName: "chevron.right")
|
||||||
case .paused: return .orange
|
.font(.caption)
|
||||||
case .idle: return .gray
|
.foregroundStyle(.secondary)
|
||||||
case .off: return .red
|
|
||||||
}
|
}
|
||||||
|
.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,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct ArtistsView: View {
|
struct ArtistsView: View {
|
||||||
@Environment(MAService.self) private var service
|
@Environment(MAService.self) private var service
|
||||||
@@ -21,13 +22,45 @@ struct ArtistsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ZStack(alignment: .trailing) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: columns, spacing: 16) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(artists) { artist in
|
ForEach(artistsByLetter, id: \.0) { letter, letterArtists in
|
||||||
|
// Section header
|
||||||
|
Text(letter)
|
||||||
|
.font(.headline)
|
||||||
|
.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) {
|
NavigationLink(value: artist) {
|
||||||
ArtistGridItem(artist: artist)
|
ArtistGridItem(artist: artist)
|
||||||
}
|
}
|
||||||
@@ -36,25 +69,35 @@ struct ArtistsView: View {
|
|||||||
await loadMoreIfNeeded(currentItem: artist)
|
await loadMoreIfNeeded(currentItem: artist)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
}
|
||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.gridCellColumns(columns.count)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
// Right padding leaves room for the alphabet index
|
||||||
|
.padding(.trailing, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating alphabet index on the right edge
|
||||||
|
if !availableLetters.isEmpty {
|
||||||
|
AlphabetIndexView(letters: availableLetters) { letter in
|
||||||
|
proxy.scrollTo(letter, anchor: .top)
|
||||||
|
}
|
||||||
|
.padding(.trailing, 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.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) { }
|
||||||
@@ -93,6 +136,57 @@ 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 {
|
||||||
@@ -100,37 +194,27 @@ struct ArtistGridItem: View {
|
|||||||
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 {
|
|
||||||
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
|
|
||||||
|
|
||||||
CachedAsyncImage(url: coverURL) { image in
|
|
||||||
image
|
image
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
} placeholder: {
|
} placeholder: {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.gray.opacity(0.2))
|
.fill(Color.gray.opacity(0.2))
|
||||||
}
|
|
||||||
.frame(width: 160, height: 160)
|
|
||||||
.clipShape(Circle())
|
|
||||||
} else {
|
|
||||||
Circle()
|
|
||||||
.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)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
// Artist Name
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user