A modern Music Player Daemon based on Rockbox open source high quality audio player
libadwaita audio rust zig deno mpris rockbox mpd

Add Queue UI, CachedAsyncImage, playlist helpers

Add a macOS QueueView, CachedAsyncImage component and integrate a
showQueue binding/inspector into player controls and ContentView. Add
Swift gRPC helpers for streaming/starting playlists.

Regenerate tonic-generated Rust clients (formatting and trait bound
wrapping) and fix artist name capitalization in the metadata updater.
Adjust album grid item minimum width. Add Queue UI, image cache, and
playlist helpers

+442 -43
+13 -4
crates/library/src/artists.rs
··· 34 thread::spawn(move || { 35 let runtime = tokio::runtime::Runtime::new().unwrap(); 36 let result = runtime.block_on(async { 37 - let artists = repo::artist::all(pool.clone()).await?; 38 - let artists = artists.into_iter().filter(|v| v.image.is_none()); 39 let mut artist_map: HashMap<String, Artist> = HashMap::new(); 40 - let names = artists.map(|artist| artist.name).collect::<Vec<String>>(); 41 42 let client = reqwest::Client::new(); 43 let response = client ··· 59 60 println!("Loaded {} artists", artists.len()); 61 62 - for artist in artists { 63 println!("Updating artist: {}", artist.name.bright_green()); 64 let artist_id = artist.id; 65 if let Some(artist) = artist_map.get(&artist.name) {
··· 34 thread::spawn(move || { 35 let runtime = tokio::runtime::Runtime::new().unwrap(); 36 let result = runtime.block_on(async { 37 + let local_artists = repo::artist::all(pool.clone()).await?; 38 + let local_artists = local_artists.into_iter().filter(|v| v.image.is_none()); 39 + let local_artists = local_artists.map(|mut artist| { 40 + if artist.name == "Theory Of A Deadman" { 41 + artist.name = "Theory of a Deadman".to_string(); 42 + } 43 + artist 44 + }); 45 let mut artist_map: HashMap<String, Artist> = HashMap::new(); 46 + let names = local_artists 47 + .clone() 48 + .map(|artist| artist.name) 49 + .collect::<Vec<String>>(); 50 51 let client = reqwest::Client::new(); 52 let response = client ··· 68 69 println!("Loaded {} artists", artists.len()); 70 71 + for artist in local_artists { 72 println!("Updating artist: {}", artist.name.bright_green()); 73 let artist_id = artist.id; 74 if let Some(artist) = artist_map.get(&artist.name) {
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+41
macos/Rockbox/Services/PlaylistService.swift
··· 37 let _ = try await playlist.resumeTrack(req) 38 } 39 }
··· 37 let _ = try await playlist.resumeTrack(req) 38 } 39 } 40 + 41 + func currentPlaylistStream() -> AsyncThrowingStream<Rockbox_V1alpha1_PlaylistResponse, Error> { 42 + AsyncThrowingStream { continuation in 43 + Task { 44 + do { 45 + try await withGRPCClient( 46 + transport: .http2NIOPosix( 47 + target: .dns(host: "127.0.0.1", port: 6061), 48 + transportSecurity: .plaintext 49 + ) 50 + ) { grpcClient in 51 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 52 + let req = Rockbox_V1alpha1_StreamPlaylistRequest() 53 + 54 + try await playback.streamPlaylist(req) { response in 55 + for try await message in response.messages { 56 + continuation.yield(message) 57 + } 58 + continuation.finish() 59 + } 60 + } 61 + } catch { 62 + continuation.finish(throwing: error) 63 + } 64 + } 65 + } 66 + } 67 + 68 + func startPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 69 + try await withGRPCClient( 70 + transport: .http2NIOPosix( 71 + target: .dns(host: host, port: port), 72 + transportSecurity: .plaintext 73 + ) 74 + ) { grpcClient in 75 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 76 + var req = Rockbox_V1alpha1_StartRequest() 77 + req.startIndex = position 78 + let _ = try await playlist.start(req) 79 + } 80 + }
+122 -13
macos/Rockbox/State/PlayerState.swift
··· 4 // 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 // 7 - 8 import Foundation 9 import SwiftUI 10 ··· 14 @Published var currentTime: TimeInterval = 0 15 @Published var duration: TimeInterval = 0 16 @Published var currentTrack = Song(cuid: "", title: "Not Playing", artist: "", album: "", albumArt: nil, duration: TimeInterval(0), trackNumber: 0, discNumber: 0, color: .gray.opacity(0.3)) 17 @Published var isConnected = false 18 @Published var error: Error? 19 @Published var status: Int32 = 0 20 21 private var streamTask: Task<Void, Never>? 22 private var streamStatusTask: Task<Void, Never>? 23 24 var progress: Double { 25 get { duration > 0 ? currentTime / duration : 0 } 26 set { currentTime = newValue * duration } 27 } 28 29 func startStreaming() { ··· 33 do { 34 isConnected = true 35 for try await response in currentTrackStream() { 36 - self.currentTrack = Song(cuid: response.id, title: response.title, artist: response.artist, album: response.album, albumArt: URL(string: "http://localhost:6062/covers/" + response.albumArt), duration: TimeInterval(response.length / 1000), trackNumber: Int(response.tracknum), discNumber: Int(response.discnum), color: .gray.opacity(0.3)) 37 self.duration = TimeInterval(response.length / 1000) 38 - self.currentTime = TimeInterval(response.elapsed / 1000) 39 } 40 } catch is CancellationError { 41 // Ignored 42 } catch { ··· 49 streamStatusTask = Task { 50 do { 51 for try await response in playbackStatusStream() { 52 - self.isPlaying = response.status == 1 53 - self.status = response.status 54 } 55 } catch is CancellationError { 56 - // Ignoted 57 - } catch { 58 self.error = error 59 } 60 } ··· 72 do { 73 let data = try await fetchCurrentPlaylist() 74 if data.tracks.count > 0 { 75 - let currentIndex: Int = Int(data.index) 76 - self.currentTrack = Song(cuid: data.tracks[currentIndex].id, title: data.tracks[currentIndex].title, artist: data.tracks[currentIndex].artist, album: data.tracks[currentIndex].album, albumArt: URL(string: "http://localhost:6062/covers/" + data.tracks[currentIndex].albumArt), duration: TimeInterval(data.tracks[currentIndex].length / 1000), trackNumber: Int(data.tracks[currentIndex].tracknum), discNumber: Int(data.tracks[currentIndex].discnum), color: .gray.opacity(0.3)) 77 let globalStatus = try await fetchGlobalStatus() 78 self.currentTime = TimeInterval(globalStatus.resumeElapsed / 1000) 79 - self.duration = TimeInterval(data.tracks[currentIndex].length / 1000) 80 } 81 } catch { 82 self.error = error ··· 84 } 85 } 86 87 func playPreviousTrack() { 88 Task { 89 do { ··· 92 self.error = error 93 } 94 } 95 - 96 } 97 98 func playNextTrack() { ··· 116 117 if isPlaying { 118 try await pause() 119 - try? await Task.sleep(for: .seconds(1)) 120 return 121 } 122 ··· 138 } 139 } 140 141 - }
··· 4 // 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 // 7 import Foundation 8 import SwiftUI 9 ··· 13 @Published var currentTime: TimeInterval = 0 14 @Published var duration: TimeInterval = 0 15 @Published var currentTrack = Song(cuid: "", title: "Not Playing", artist: "", album: "", albumArt: nil, duration: TimeInterval(0), trackNumber: 0, discNumber: 0, color: .gray.opacity(0.3)) 16 + @Published var queue: [Song] = [] 17 + @Published var currentIndex: Int = 0 18 + @Published var playlistLength: Int = 0 19 @Published var isConnected = false 20 @Published var error: Error? 21 @Published var status: Int32 = 0 22 23 private var streamTask: Task<Void, Never>? 24 private var streamStatusTask: Task<Void, Never>? 25 + private var streamPlaylistTask: Task<Void, Never>? 26 27 var progress: Double { 28 get { duration > 0 ? currentTime / duration : 0 } 29 set { currentTime = newValue * duration } 30 + } 31 + 32 + // Upcoming tracks (after current) 33 + var upNext: [Song] { 34 + guard currentIndex + 1 < queue.count else { return [] } 35 + return Array(queue[(currentIndex + 1)...]) 36 + } 37 + 38 + // Previous tracks (before current + current) 39 + var history: [Song] { 40 + guard currentIndex > 0 else { return [] } 41 + return Array(queue[...currentIndex]) 42 } 43 44 func startStreaming() { ··· 48 do { 49 isConnected = true 50 for try await response in currentTrackStream() { 51 + self.currentTrack = Song( 52 + cuid: response.id, 53 + title: response.title, 54 + artist: response.artist, 55 + album: response.album, 56 + albumArt: URL(string: "http://localhost:6062/covers/" + response.albumArt), 57 + duration: TimeInterval(response.length / 1000), 58 + trackNumber: Int(response.tracknum), 59 + discNumber: Int(response.discnum), 60 + color: .gray.opacity(0.3) 61 + ) 62 self.duration = TimeInterval(response.length / 1000) 63 + self.currentTime = TimeInterval(response.elapsed / 1000) 64 } 65 + // Refresh queue when track changes 66 + await self.fetchQueue() 67 + 68 } catch is CancellationError { 69 // Ignored 70 } catch { ··· 77 streamStatusTask = Task { 78 do { 79 for try await response in playbackStatusStream() { 80 + self.isPlaying = response.status == 1 81 + self.status = response.status 82 + } 83 + } catch is CancellationError { 84 + // Ignored 85 + } catch { 86 + self.error = error 87 + } 88 + } 89 + 90 + streamPlaylistTask?.cancel() 91 + streamPlaylistTask = Task { 92 + do { 93 + for try await data in currentPlaylistStream() { 94 + if self.currentIndex == Int(data.index) { continue } 95 + self.currentIndex = Int(data.index) 96 + self.playlistLength = Int(data.amount) 97 + self.queue = data.tracks.map { track in 98 + Song( 99 + cuid: track.id, 100 + title: track.title, 101 + artist: track.artist, 102 + album: track.album, 103 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 104 + duration: TimeInterval(track.length / 1000), 105 + trackNumber: Int(track.tracknum), 106 + discNumber: Int(track.discnum), 107 + color: .gray.opacity(0.3) 108 + ) 109 + } 110 + 111 + self.currentTrack = self.queue[self.currentIndex] 112 } 113 } catch is CancellationError { 114 + // Ignored 115 + } catch { 116 self.error = error 117 } 118 } ··· 130 do { 131 let data = try await fetchCurrentPlaylist() 132 if data.tracks.count > 0 { 133 + let index = Int(data.index) 134 + self.currentIndex = index 135 + self.playlistLength = Int(data.amount) 136 + 137 + self.queue = data.tracks.map { track in 138 + Song( 139 + cuid: track.id, 140 + title: track.title, 141 + artist: track.artist, 142 + album: track.album, 143 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 144 + duration: TimeInterval(track.length / 1000), 145 + trackNumber: Int(track.tracknum), 146 + discNumber: Int(track.discnum), 147 + color: .gray.opacity(0.3) 148 + ) 149 + } 150 + 151 + self.currentTrack = self.queue[index] 152 + 153 let globalStatus = try await fetchGlobalStatus() 154 self.currentTime = TimeInterval(globalStatus.resumeElapsed / 1000) 155 + self.duration = TimeInterval(data.tracks[index].length / 1000) 156 } 157 } catch { 158 self.error = error ··· 160 } 161 } 162 163 + func fetchQueue() async { 164 + do { 165 + let data = try await fetchCurrentPlaylist() 166 + if data.tracks.count > 0 { 167 + self.currentIndex = Int(data.index) 168 + self.queue = data.tracks.map { track in 169 + Song( 170 + cuid: track.id, 171 + title: track.title, 172 + artist: track.artist, 173 + album: track.album, 174 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 175 + duration: TimeInterval(track.length / 1000), 176 + trackNumber: Int(track.tracknum), 177 + discNumber: Int(track.discnum), 178 + color: .gray.opacity(0.3) 179 + ) 180 + } 181 + } 182 + } catch { 183 + self.error = error 184 + } 185 + } 186 + 187 func playPreviousTrack() { 188 Task { 189 do { ··· 192 self.error = error 193 } 194 } 195 } 196 197 func playNextTrack() { ··· 215 216 if isPlaying { 217 try await pause() 218 return 219 } 220 ··· 236 } 237 } 238 239 + func playFromQueue(at index: Int) { 240 + Task { 241 + do { 242 + try await startPlaylist(position: Int32(index)) 243 + self.currentIndex = index 244 + self.currentTrack = queue[index] 245 + } catch { 246 + self.error = error 247 + } 248 + } 249 + } 250 + }
+1 -1
macos/Rockbox/Views/Albums/AlbumsGridView.swift
··· 13 @Binding var selectedAlbum: Album? 14 15 private let columns = [ 16 - GridItem(.adaptive(minimum: 214, maximum: 230), spacing: 20) 17 ] 18 19 var body: some View {
··· 13 @Binding var selectedAlbum: Album? 14 15 private let columns = [ 16 + GridItem(.adaptive(minimum: 170, maximum: 230), spacing: 20) 17 ] 18 19 var body: some View {
+105
macos/Rockbox/Views/Components/CachedAsyncImage.swift
···
··· 1 + // 2 + // CachedImageLoader.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 20/12/2025. 6 + // 7 + import SwiftUI 8 + 9 + enum CachedAsyncImagePhase { 10 + case empty 11 + case success(Image) 12 + case failure(Error) 13 + } 14 + 15 + struct CachedAsyncImage<Content: View>: View { 16 + let url: URL? 17 + let content: (CachedAsyncImagePhase) -> Content 18 + 19 + @State private var phase: CachedAsyncImagePhase = .empty 20 + 21 + init(url: URL?, @ViewBuilder content: @escaping (CachedAsyncImagePhase) -> Content) { 22 + self.url = url 23 + self.content = content 24 + } 25 + 26 + var body: some View { 27 + content(phase) 28 + .onAppear { 29 + loadImage() 30 + } 31 + .onChange(of: url) { 32 + loadImage() 33 + } 34 + } 35 + 36 + private func loadImage() { 37 + guard let url = url else { 38 + phase = .empty 39 + return 40 + } 41 + 42 + // Check cache first 43 + if let cached = ImageCache.shared.image(for: url) { 44 + phase = .success(Image(nsImage: cached)) 45 + return 46 + } 47 + 48 + // Reset to loading state 49 + phase = .empty 50 + 51 + // Load from network 52 + Task { 53 + do { 54 + let (data, _) = try await URLSession.shared.data(from: url) 55 + if let nsImage = NSImage(data: data) { 56 + ImageCache.shared.setImage(nsImage, for: url) 57 + phase = .success(Image(nsImage: nsImage)) 58 + } else { 59 + phase = .failure(URLError(.cannotDecodeContentData)) 60 + } 61 + } catch { 62 + phase = .failure(error) 63 + } 64 + } 65 + } 66 + } 67 + 68 + // Convenience initializer for simple usage 69 + extension CachedAsyncImage where Content == AnyView { 70 + init(url: URL?) { 71 + self.url = url 72 + self.content = { phase in 73 + AnyView( 74 + Group { 75 + switch phase { 76 + case .empty: 77 + ProgressView() 78 + .scaleEffect(0.5) 79 + case .success(let image): 80 + image 81 + .resizable() 82 + .aspectRatio(contentMode: .fill) 83 + case .failure: 84 + Image(systemName: "music.note") 85 + .foregroundStyle(.white.opacity(0.6)) 86 + } 87 + } 88 + ) 89 + } 90 + } 91 + } 92 + 93 + @MainActor 94 + class ImageCache { 95 + static let shared = ImageCache() 96 + private var cache = NSCache<NSURL, NSImage>() 97 + 98 + func image(for url: URL) -> NSImage? { 99 + cache.object(forKey: url as NSURL) 100 + } 101 + 102 + func setImage(_ image: NSImage, for url: URL) { 103 + cache.setObject(image, forKey: url as NSURL) 104 + } 105 + }
+29 -22
macos/Rockbox/Views/Components/PlayerControlsView.swift
··· 11 @EnvironmentObject var player: PlayerState 12 @State private var isHoveringProgress = false 13 @State private var isHoveringTrackInfo = false 14 @ObservedObject var library: MusicLibrary 15 16 var body: some View { 17 HStack(spacing: 0) { 18 - // Playback controls (left, but centered in its space) 19 - HStack( alignment: .center, spacing: 16) { 20 Button(action: { player.playPreviousTrack() }) { 21 Image(systemName: "backward.fill") 22 .font(.system(size: 13)) ··· 66 } 67 } 68 } 69 - .clipShape(RoundedRectangle(cornerRadius: 0)) 70 - 71 72 VStack(alignment: .leading, spacing: 0) { 73 // Track metadata with heart button ··· 84 .lineLimit(1) 85 } 86 87 - // Heart button (shows on hover or when liked) 88 Button(action: { 89 withAnimation(.easeInOut(duration: 0.2)) { 90 - library.toggleLike(player.currentTrack) } 91 }) { 92 Image(systemName: library.isLiked(player.currentTrack) ? "heart.fill" : "heart") 93 .font(.system(size: 12)) ··· 111 112 GeometryReader { geometry in 113 ZStack(alignment: .leading) { 114 - // Track background 115 Capsule() 116 .fill(.quaternary) 117 .frame(height: isHoveringProgress ? 6 : 3) 118 119 - // Progress fill 120 Capsule() 121 .fill(.primary.opacity(0.8)) 122 .frame(width: geometry.size.width * player.progress, height: isHoveringProgress ? 6 : 3) ··· 156 } 157 } 158 159 - // Volume (right) 160 - HStack(spacing: 8) { 161 - Image(systemName: "speaker.fill") 162 - .font(.system(size: 10)) 163 - .foregroundStyle(.secondary) 164 - 165 - Slider(value: .constant(0.7)) 166 - .frame(width: 80) 167 - 168 - Image(systemName: "speaker.wave.3.fill") 169 - .font(.system(size: 10)) 170 - .foregroundStyle(.secondary) 171 } 172 - .frame(width: 120) 173 } 174 .padding(.horizontal, 16) 175 .padding(.vertical, 10) ··· 182 return String(format: "%d:%02d", minutes, seconds) 183 } 184 } 185 -
··· 11 @EnvironmentObject var player: PlayerState 12 @State private var isHoveringProgress = false 13 @State private var isHoveringTrackInfo = false 14 + @State private var isHoveringQueue = false 15 @ObservedObject var library: MusicLibrary 16 + @Binding var showQueue: Bool // Add this binding 17 18 var body: some View { 19 HStack(spacing: 0) { 20 + // Playback controls (left) 21 + HStack(alignment: .center, spacing: 16) { 22 Button(action: { player.playPreviousTrack() }) { 23 Image(systemName: "backward.fill") 24 .font(.system(size: 13)) ··· 68 } 69 } 70 } 71 + .clipShape(RoundedRectangle(cornerRadius: 4)) 72 73 VStack(alignment: .leading, spacing: 0) { 74 // Track metadata with heart button ··· 85 .lineLimit(1) 86 } 87 88 + // Heart button 89 Button(action: { 90 withAnimation(.easeInOut(duration: 0.2)) { 91 + library.toggleLike(player.currentTrack) 92 + } 93 }) { 94 Image(systemName: library.isLiked(player.currentTrack) ? "heart.fill" : "heart") 95 .font(.system(size: 12)) ··· 113 114 GeometryReader { geometry in 115 ZStack(alignment: .leading) { 116 Capsule() 117 .fill(.quaternary) 118 .frame(height: isHoveringProgress ? 6 : 3) 119 120 Capsule() 121 .fill(.primary.opacity(0.8)) 122 .frame(width: geometry.size.width * player.progress, height: isHoveringProgress ? 6 : 3) ··· 156 } 157 } 158 159 + Button(action: { 160 + withAnimation(.easeInOut(duration: 0.2)) { 161 + showQueue.toggle() 162 + } 163 + }) { 164 + Image(systemName: "list.bullet") 165 + .font(.system(size: 14)) 166 + .foregroundStyle(showQueue ? .primary : .secondary) 167 + .frame(width: 32, height: 32) 168 + .background( 169 + RoundedRectangle(cornerRadius: 6) 170 + .fill(isHoveringQueue || showQueue ? Color.secondary.opacity(0.15) : Color.clear) 171 + ) 172 + .contentShape(Rectangle()) 173 + } 174 + .buttonStyle(.plain) 175 + .onHover { hovering in 176 + withAnimation(.easeInOut(duration: 0.1)) { 177 + isHoveringQueue = hovering 178 + } 179 } 180 + .frame(width: 60) 181 } 182 .padding(.horizontal, 16) 183 .padding(.vertical, 10) ··· 190 return String(format: "%d:%02d", minutes, seconds) 191 } 192 }
+122
macos/Rockbox/Views/Components/QueueView.swift
···
··· 1 + // 2 + // QueueView.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 20/12/2025. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct QueueView: View { 11 + @EnvironmentObject var player: PlayerState 12 + @State private var showPlayingNext: Bool = true 13 + 14 + var body: some View { 15 + VStack(alignment: .leading, spacing: 0) { 16 + // Header 17 + HStack { 18 + Text(!showPlayingNext ? "History" : "Playing Next") 19 + .font(.headline) 20 + .padding() 21 + 22 + Spacer() 23 + 24 + Text("\(player.currentIndex + 1) of \(player.playlistLength)") 25 + .foregroundStyle(.secondary) 26 + .padding() 27 + 28 + Spacer() 29 + 30 + Button(action: { 31 + showPlayingNext.toggle() 32 + }) { 33 + Text(showPlayingNext ? "History" : "Playing Next") 34 + .padding() 35 + }.buttonStyle(.borderless) 36 + 37 + } 38 + 39 + Divider() 40 + 41 + if player.upNext.isEmpty { 42 + VStack(spacing: 12) { 43 + Image(systemName: "music.note.list") 44 + .font(.system(size: 32)) 45 + .foregroundStyle(.tertiary) 46 + Text("No upcoming songs") 47 + .foregroundStyle(.secondary) 48 + } 49 + .frame(maxWidth: .infinity, maxHeight: .infinity) 50 + } else { 51 + ScrollView { 52 + LazyVStack(spacing: 0) { 53 + ForEach(Array(showPlayingNext ? player.upNext.enumerated() : player.history.enumerated()), id: \.element.id) { index, song in 54 + QueueRowView( 55 + song: song, 56 + index: index, 57 + onTap: { 58 + player.playFromQueue(at: showPlayingNext ? player.currentIndex + 1 + index : index) 59 + } 60 + ) 61 + } 62 + } 63 + } 64 + } 65 + } 66 + .frame(minWidth: 300) 67 + .background(.background) 68 + } 69 + } 70 + 71 + struct QueueRowView: View { 72 + let song: Song 73 + let index: Int 74 + var onTap: () -> Void 75 + @State private var isHovering = false 76 + 77 + var body: some View { 78 + HStack(spacing: 12) { 79 + // Album art 80 + RoundedRectangle(cornerRadius: 4) 81 + .fill(song.color.gradient) 82 + .frame(width: 40, height: 40) 83 + .overlay { 84 + CachedAsyncImage(url: song.albumArt) { phase in 85 + switch phase { 86 + case .success(let image): 87 + image 88 + .resizable() 89 + .aspectRatio(contentMode: .fill) 90 + default: 91 + Image(systemName: "music.note") 92 + .font(.system(size: 12)) 93 + .foregroundStyle(.white.opacity(0.6)) 94 + } 95 + } 96 + } 97 + .clipShape(RoundedRectangle(cornerRadius: 4)) 98 + 99 + VStack(alignment: .leading, spacing: 2) { 100 + Text(song.title) 101 + .font(.system(size: 13)) 102 + .lineLimit(1) 103 + 104 + Text(song.artist) 105 + .font(.system(size: 11)) 106 + .foregroundStyle(.secondary) 107 + .lineLimit(1) 108 + } 109 + 110 + Spacer() 111 + } 112 + .padding(.horizontal, 12) 113 + .padding(.vertical, 8) 114 + .background(isHovering ? Color.secondary.opacity(0.1) : Color.clear) 115 + .contentShape(Rectangle()) 116 + .onTapGesture { 117 + onTap() 118 + } 119 + .onHover { isHovering = $0 } 120 + } 121 + } 122 +
+6 -2
macos/Rockbox/Views/Main/ContentView.swift
··· 11 @State private var selection: SidebarItem? = .albums 12 @StateObject private var player = PlayerState() 13 @StateObject private var library = MusicLibrary() 14 - 15 16 var body: some View { 17 NavigationSplitView { 18 Sidebar(selection: $selection) 19 } detail: { 20 - DetailView(selection: selection, player: player, library: library) 21 } 22 } 23 } 24
··· 11 @State private var selection: SidebarItem? = .albums 12 @StateObject private var player = PlayerState() 13 @StateObject private var library = MusicLibrary() 14 + @State private var showQueue = false 15 16 var body: some View { 17 NavigationSplitView { 18 Sidebar(selection: $selection) 19 } detail: { 20 + DetailView(selection: selection, player: player, library: library, showQueue: $showQueue) 21 } 22 + .inspector(isPresented: $showQueue) { 23 + QueueView() 24 + .inspectorColumnWidth(min: 280, ideal: 300, max: 350) 25 + } 26 } 27 } 28
+3 -1
macos/Rockbox/Views/Main/DetailView.swift
··· 13 @ObservedObject var library: MusicLibrary 14 @State private var selectedAlbum: Album? = nil 15 @State private var selectedArtist: Artist? = nil 16 17 var body: some View { 18 VStack(spacing: 0) { ··· 53 Divider() 54 55 // Player controls 56 - PlayerControlsView(library: library) 57 } 58 .frame(maxWidth: .infinity, maxHeight: .infinity) 59 .onChange(of: selection) {
··· 13 @ObservedObject var library: MusicLibrary 14 @State private var selectedAlbum: Album? = nil 15 @State private var selectedArtist: Artist? = nil 16 + @Binding var showQueue: Bool 17 + 18 19 var body: some View { 20 VStack(spacing: 0) { ··· 55 Divider() 56 57 // Player controls 58 + PlayerControlsView(library: library, showQueue: $showQueue) 59 } 60 .frame(maxWidth: .infinity, maxHeight: .infinity) 61 .onChange(of: selection) {