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 CryptoKit
|
||||
import OSLog
|
||||
|
||||
private let imgLogger = Logger(subsystem: "com.musicassistant.mobile", category: "ImageCache")
|
||||
|
||||
// MARK: - Image Cache
|
||||
|
||||
final class ImageCache: @unchecked Sendable {
|
||||
static let shared = ImageCache()
|
||||
|
||||
// NSCache: auto-evicts under memory pressure (large buffer)
|
||||
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 fileManager = FileManager.default
|
||||
|
||||
@@ -30,23 +40,44 @@ final class ImageCache: @unchecked Sendable {
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
func image(for key: String) -> UIImage? {
|
||||
// 1. Memory
|
||||
if let img = memory.object(forKey: key as NSString) { return img }
|
||||
// 2. Disk
|
||||
/// Check in-memory caches (LRU + NSCache). Thread-safe, no disk I/O — safe on main thread.
|
||||
func memoryImage(for key: String) -> UIImage? {
|
||||
lock.withLock {
|
||||
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)
|
||||
guard let data = try? Data(contentsOf: file),
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
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.
|
||||
var diskUsageBytes: Int {
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
@@ -63,7 +94,11 @@ final class ImageCache: @unchecked Sendable {
|
||||
|
||||
/// Remove all cached artwork from disk and memory.
|
||||
func clearAll() {
|
||||
memory.removeAllObjects()
|
||||
lock.withLock {
|
||||
memory.removeAllObjects()
|
||||
lruImages.removeAll()
|
||||
lruKeys.removeAll()
|
||||
}
|
||||
try? fileManager.removeItem(at: directory)
|
||||
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
@@ -71,7 +106,7 @@ final class ImageCache: @unchecked Sendable {
|
||||
|
||||
// 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.
|
||||
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@@ -90,6 +125,12 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
self.url = url
|
||||
self.content = content
|
||||
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 {
|
||||
@@ -108,16 +149,26 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
|
||||
private func loadImage() async {
|
||||
guard let url else { return }
|
||||
|
||||
let key = ImageCache.shared.cacheKey(for: url)
|
||||
|
||||
// Serve from cache instantly if available
|
||||
if let cached = ImageCache.shared.image(for: key) {
|
||||
// 1. Memory (LRU + NSCache) — instant, no I/O
|
||||
if let cached = ImageCache.shared.memoryImage(for: key) {
|
||||
image = cached
|
||||
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)
|
||||
if let token = service.authManager.currentToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
@@ -125,13 +176,16 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
// Only cache successful responses
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return }
|
||||
guard !Task.isCancelled else { return }
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard status == 200 else { return }
|
||||
guard let uiImage = UIImage(data: data) else { return }
|
||||
ImageCache.shared.store(uiImage, data: data, for: key)
|
||||
image = uiImage
|
||||
} 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
|
||||
|
||||
enum PlayerSelection {
|
||||
case localPlayer
|
||||
case remotePlayer(MAPlayer)
|
||||
}
|
||||
|
||||
struct EnhancedPlayerPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(MAService.self) private var service
|
||||
|
||||
let players: [MAPlayer]
|
||||
let supportsLocalPlayback: Bool
|
||||
let onSelect: (PlayerSelection) -> Void
|
||||
|
||||
let onSelect: (MAPlayer) -> Void
|
||||
|
||||
/// IDs of all players that are sync members (not the leader)
|
||||
private var syncedMemberIds: Set<String> {
|
||||
Set(players.flatMap { $0.groupChilds })
|
||||
}
|
||||
|
||||
private var groupLeaders: [MAPlayer] {
|
||||
players.filter { $0.isGroupLeader }
|
||||
}
|
||||
|
||||
private var soloPlayers: [MAPlayer] {
|
||||
players.filter { !$0.isGroupLeader && !syncedMemberIds.contains($0.playerId) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Local iPhone Player
|
||||
if supportsLocalPlayback {
|
||||
Section {
|
||||
Button {
|
||||
onSelect(.localPlayer)
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
// Group cards at the top
|
||||
ForEach(groupLeaders) { leader in
|
||||
let memberNames = leader.groupChilds
|
||||
.compactMap { service.playerManager.players[$0]?.name }
|
||||
PickerGroupCard(leader: leader, memberNames: memberNames) {
|
||||
onSelect(leader)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// Solo player cards
|
||||
ForEach(soloPlayers) { player in
|
||||
PickerPlayerCard(player: player) {
|
||||
onSelect(player)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "iphone")
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("This iPhone")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Play directly on this device")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Local Playback")
|
||||
}
|
||||
}
|
||||
|
||||
// Remote Players
|
||||
if !players.isEmpty {
|
||||
Section {
|
||||
ForEach(players) { player in
|
||||
Button {
|
||||
onSelect(.remotePlayer(player))
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(player.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: stateIcon(for: player.state))
|
||||
.foregroundStyle(stateColor(for: player.state))
|
||||
.font(.caption)
|
||||
Text(player.state.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.disabled(!player.available)
|
||||
}
|
||||
} header: {
|
||||
Text("Remote Players")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.navigationTitle("Play on...")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateIcon(for state: PlayerState) -> String {
|
||||
switch state {
|
||||
case .playing: return "play.circle.fill"
|
||||
case .paused: return "pause.circle.fill"
|
||||
case .idle: return "stop.circle"
|
||||
case .off: return "power.circle"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picker Player Card
|
||||
|
||||
private struct PickerPlayerCard: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let player: MAPlayer
|
||||
let onSelect: () -> Void
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[player.playerId]?.currentItem
|
||||
}
|
||||
|
||||
private func stateColor(for state: PlayerState) -> Color {
|
||||
switch state {
|
||||
case .playing: return .green
|
||||
case .paused: return .orange
|
||||
case .idle: return .gray
|
||||
case .off: return .red
|
||||
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
if player.state == .playing {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(player.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let item = currentItem {
|
||||
Text(item.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let artists = mediaItem?.artists, !artists.isEmpty {
|
||||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Text(player.state == .off ? "Powered Off" : "No Track Playing")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
ZStack {
|
||||
CachedAsyncImage(url: service.imageProxyURL(
|
||||
path: mediaItem?.imageUrl,
|
||||
provider: mediaItem?.imageProvider,
|
||||
size: 256
|
||||
)) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.clear
|
||||
}
|
||||
.blur(radius: 20)
|
||||
.scaleEffect(1.1)
|
||||
.clipped()
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
.onTapGesture { onSelect() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picker Group Card
|
||||
|
||||
private struct PickerGroupCard: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let leader: MAPlayer
|
||||
let memberNames: [String]
|
||||
let onSelect: () -> Void
|
||||
|
||||
private var currentItem: MAQueueItem? {
|
||||
service.playerManager.playerQueues[leader.playerId]?.currentItem
|
||||
}
|
||||
private var mediaItem: MAMediaItem? { currentItem?.mediaItem }
|
||||
|
||||
private var groupName: String {
|
||||
([leader.name] + memberNames).joined(separator: " + ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "speaker.2.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
if leader.state == .playing {
|
||||
Image(systemName: "waveform")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
Text(groupName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let item = currentItem {
|
||||
Text(item.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let artists = mediaItem?.artists, !artists.isEmpty {
|
||||
Text(artists.map { $0.name }.joined(separator: ", "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Text(leader.state == .off ? "Powered Off" : "No Track Playing")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background {
|
||||
ZStack {
|
||||
CachedAsyncImage(url: service.imageProxyURL(
|
||||
path: mediaItem?.imageUrl,
|
||||
provider: mediaItem?.imageProvider,
|
||||
size: 256
|
||||
)) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Color.clear
|
||||
}
|
||||
.blur(radius: 20)
|
||||
.scaleEffect(1.1)
|
||||
.clipped()
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 16))
|
||||
.onTapGesture { onSelect() }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EnhancedPlayerPickerView(
|
||||
players: [],
|
||||
supportsLocalPlayback: true,
|
||||
onSelect: { _ in }
|
||||
)
|
||||
.environment(MAService())
|
||||
}
|
||||
|
||||
@@ -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 UIKit
|
||||
|
||||
struct ArtistsView: View {
|
||||
@Environment(MAService.self) private var service
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
|
||||
private var artists: [MAArtist] {
|
||||
service.libraryManager.artists
|
||||
}
|
||||
|
||||
|
||||
private var isLoading: Bool {
|
||||
service.libraryManager.isLoadingArtists
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(artists) { artist in
|
||||
NavigationLink(value: artist) {
|
||||
ArtistGridItem(artist: artist)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task {
|
||||
await loadMoreIfNeeded(currentItem: artist)
|
||||
ScrollViewReader { proxy in
|
||||
ZStack(alignment: .trailing) {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
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) {
|
||||
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 {
|
||||
ProgressView()
|
||||
.gridCellColumns(columns.count)
|
||||
.padding()
|
||||
|
||||
// Floating alphabet index on the right edge
|
||||
if !availableLetters.isEmpty {
|
||||
AlphabetIndexView(letters: availableLetters) { letter in
|
||||
proxy.scrollTo(letter, anchor: .top)
|
||||
}
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationDestination(for: MAArtist.self) { artist in
|
||||
ArtistDetailView(artist: artist)
|
||||
}
|
||||
.refreshable {
|
||||
await loadArtists(refresh: true)
|
||||
}
|
||||
.task {
|
||||
if artists.isEmpty {
|
||||
await loadArtists(refresh: false)
|
||||
}
|
||||
await loadArtists(refresh: !artists.isEmpty)
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
@@ -73,7 +116,7 @@ struct ArtistsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadArtists(refresh: Bool) async {
|
||||
do {
|
||||
try await service.libraryManager.loadArtists(refresh: refresh)
|
||||
@@ -82,7 +125,7 @@ struct ArtistsView: View {
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadMoreIfNeeded(currentItem: MAArtist) async {
|
||||
do {
|
||||
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
|
||||
|
||||
struct ArtistGridItem: View {
|
||||
@Environment(MAService.self) private var service
|
||||
let artist: MAArtist
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Artist Image
|
||||
if let imageUrl = artist.imageUrl {
|
||||
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
|
||||
|
||||
CachedAsyncImage(url: coverURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
}
|
||||
.frame(width: 160, height: 160)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
VStack(spacing: 4) {
|
||||
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 128)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(width: 160, height: 160)
|
||||
.overlay {
|
||||
Image(systemName: "music.mic")
|
||||
.font(.system(size: 40))
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Artist Name
|
||||
.frame(width: 76, height: 76)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text(artist.name)
|
||||
.font(.subheadline)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.center)
|
||||
.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