From c780be089d5dba3c441093dff5149e83ce64267a Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 5 Apr 2026 19:44:05 +0200 Subject: [PATCH] Version 1 in App Store --- .../DocsAudioStreamingIntegration.md | 145 ------ .../HelpersAudioPlayerEnvironment.swift | 20 - .../HelpersMANavigationDestination.swift | 51 ++ .../ServicesMAAudioPlayer.swift | 444 ------------------ .../ServicesMAThemeManager.swift | 76 +++ .../ViewsComponentsCachedAsyncImage.swift | 84 +++- ...wsComponentsEnhancedPlayerPickerView.swift | 290 ++++++++---- .../ViewsComponentsMiniPlayerView.swift | 103 ---- .../ViewsLibraryArtistsView.swift | 186 ++++++-- .../ViewsLocalPlayerView.swift | 233 --------- Mobile Music Assistant/ViewsPlayerView.swift | 379 --------------- ViewsPlayerNowPlayingView.swift | 217 +++++++++ 12 files changed, 744 insertions(+), 1484 deletions(-) delete mode 100644 Mobile Music Assistant/DocsAudioStreamingIntegration.md delete mode 100644 Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift create mode 100644 Mobile Music Assistant/HelpersMANavigationDestination.swift delete mode 100644 Mobile Music Assistant/ServicesMAAudioPlayer.swift create mode 100644 Mobile Music Assistant/ServicesMAThemeManager.swift delete mode 100644 Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift delete mode 100644 Mobile Music Assistant/ViewsLocalPlayerView.swift delete mode 100644 Mobile Music Assistant/ViewsPlayerView.swift create mode 100644 ViewsPlayerNowPlayingView.swift diff --git a/Mobile Music Assistant/DocsAudioStreamingIntegration.md b/Mobile Music Assistant/DocsAudioStreamingIntegration.md deleted file mode 100644 index 6f1bd5a..0000000 --- a/Mobile Music Assistant/DocsAudioStreamingIntegration.md +++ /dev/null @@ -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// -``` - -## 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) diff --git a/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift b/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift deleted file mode 100644 index ae7425f..0000000 --- a/Mobile Music Assistant/HelpersAudioPlayerEnvironment.swift +++ /dev/null @@ -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 } - } -} diff --git a/Mobile Music Assistant/HelpersMANavigationDestination.swift b/Mobile Music Assistant/HelpersMANavigationDestination.swift new file mode 100644 index 0000000..f8164bf --- /dev/null +++ b/Mobile Music Assistant/HelpersMANavigationDestination.swift @@ -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()) + } +} diff --git a/Mobile Music Assistant/ServicesMAAudioPlayer.swift b/Mobile Music Assistant/ServicesMAAudioPlayer.swift deleted file mode 100644 index 3bd2534..0000000 --- a/Mobile Music Assistant/ServicesMAAudioPlayer.swift +++ /dev/null @@ -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 - } -} diff --git a/Mobile Music Assistant/ServicesMAThemeManager.swift b/Mobile Music Assistant/ServicesMAThemeManager.swift new file mode 100644 index 0000000..5ee28b8 --- /dev/null +++ b/Mobile Music Assistant/ServicesMAThemeManager.swift @@ -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 } + } +} diff --git a/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift index 6a07849..05bed8c 100644 --- a/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift +++ b/Mobile Music Assistant/ViewsComponentsCachedAsyncImage.swift @@ -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() + // 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: View { @Environment(MAService.self) private var service @@ -90,6 +125,12 @@ struct CachedAsyncImage: 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: 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: 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)") } } } diff --git a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift index a364b63..abdf99d 100644 --- a/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift +++ b/Mobile Music Assistant/ViewsComponentsEnhancedPlayerPickerView.swift @@ -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 { + 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()) } diff --git a/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift b/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift deleted file mode 100644 index d4d010b..0000000 --- a/Mobile Music Assistant/ViewsComponentsMiniPlayerView.swift +++ /dev/null @@ -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()) -} diff --git a/Mobile Music Assistant/ViewsLibraryArtistsView.swift b/Mobile Music Assistant/ViewsLibraryArtistsView.swift index a935e21..fdc36d2 100644 --- a/Mobile Music Assistant/ViewsLibraryArtistsView.swift +++ b/Mobile Music Assistant/ViewsLibraryArtistsView.swift @@ -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) } diff --git a/Mobile Music Assistant/ViewsLocalPlayerView.swift b/Mobile Music Assistant/ViewsLocalPlayerView.swift deleted file mode 100644 index c17e432..0000000 --- a/Mobile Music Assistant/ViewsLocalPlayerView.swift +++ /dev/null @@ -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()) -} diff --git a/Mobile Music Assistant/ViewsPlayerView.swift b/Mobile Music Assistant/ViewsPlayerView.swift deleted file mode 100644 index 03b31e8..0000000 --- a/Mobile Music Assistant/ViewsPlayerView.swift +++ /dev/null @@ -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()) - } -} diff --git a/ViewsPlayerNowPlayingView.swift b/ViewsPlayerNowPlayingView.swift new file mode 100644 index 0000000..4cdf771 --- /dev/null +++ b/ViewsPlayerNowPlayingView.swift @@ -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 + } +}