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 34 thread::spawn(move || { 35 35 let runtime = tokio::runtime::Runtime::new().unwrap(); 36 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()); 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 + }); 39 45 let mut artist_map: HashMap<String, Artist> = HashMap::new(); 40 - let names = artists.map(|artist| artist.name).collect::<Vec<String>>(); 46 + let names = local_artists 47 + .clone() 48 + .map(|artist| artist.name) 49 + .collect::<Vec<String>>(); 41 50 42 51 let client = reqwest::Client::new(); 43 52 let response = client ··· 59 68 60 69 println!("Loaded {} artists", artists.len()); 61 70 62 - for artist in artists { 71 + for artist in local_artists { 63 72 println!("Updating artist: {}", artist.name.bright_green()); 64 73 let artist_id = artist.id; 65 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 37 let _ = try await playlist.resumeTrack(req) 38 38 } 39 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 4 // 5 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 6 // 7 - 8 7 import Foundation 9 8 import SwiftUI 10 9 ··· 14 13 @Published var currentTime: TimeInterval = 0 15 14 @Published var duration: TimeInterval = 0 16 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 17 19 @Published var isConnected = false 18 20 @Published var error: Error? 19 21 @Published var status: Int32 = 0 20 22 21 23 private var streamTask: Task<Void, Never>? 22 24 private var streamStatusTask: Task<Void, Never>? 25 + private var streamPlaylistTask: Task<Void, Never>? 23 26 24 27 var progress: Double { 25 28 get { duration > 0 ? currentTime / duration : 0 } 26 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]) 27 42 } 28 43 29 44 func startStreaming() { ··· 33 48 do { 34 49 isConnected = true 35 50 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)) 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 + ) 37 62 self.duration = TimeInterval(response.length / 1000) 38 - self.currentTime = TimeInterval(response.elapsed / 1000) 63 + self.currentTime = TimeInterval(response.elapsed / 1000) 39 64 } 65 + // Refresh queue when track changes 66 + await self.fetchQueue() 67 + 40 68 } catch is CancellationError { 41 69 // Ignored 42 70 } catch { ··· 49 77 streamStatusTask = Task { 50 78 do { 51 79 for try await response in playbackStatusStream() { 52 - self.isPlaying = response.status == 1 53 - self.status = response.status 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] 54 112 } 55 113 } catch is CancellationError { 56 - // Ignoted 57 - } catch { 114 + // Ignored 115 + } catch { 58 116 self.error = error 59 117 } 60 118 } ··· 72 130 do { 73 131 let data = try await fetchCurrentPlaylist() 74 132 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)) 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 + 77 153 let globalStatus = try await fetchGlobalStatus() 78 154 self.currentTime = TimeInterval(globalStatus.resumeElapsed / 1000) 79 - self.duration = TimeInterval(data.tracks[currentIndex].length / 1000) 155 + self.duration = TimeInterval(data.tracks[index].length / 1000) 80 156 } 81 157 } catch { 82 158 self.error = error ··· 84 160 } 85 161 } 86 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 + 87 187 func playPreviousTrack() { 88 188 Task { 89 189 do { ··· 92 192 self.error = error 93 193 } 94 194 } 95 - 96 195 } 97 196 98 197 func playNextTrack() { ··· 116 215 117 216 if isPlaying { 118 217 try await pause() 119 - try? await Task.sleep(for: .seconds(1)) 120 218 return 121 219 } 122 220 ··· 138 236 } 139 237 } 140 238 141 - } 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 13 @Binding var selectedAlbum: Album? 14 14 15 15 private let columns = [ 16 - GridItem(.adaptive(minimum: 214, maximum: 230), spacing: 20) 16 + GridItem(.adaptive(minimum: 170, maximum: 230), spacing: 20) 17 17 ] 18 18 19 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 11 @EnvironmentObject var player: PlayerState 12 12 @State private var isHoveringProgress = false 13 13 @State private var isHoveringTrackInfo = false 14 + @State private var isHoveringQueue = false 14 15 @ObservedObject var library: MusicLibrary 16 + @Binding var showQueue: Bool // Add this binding 15 17 16 18 var body: some View { 17 19 HStack(spacing: 0) { 18 - // Playback controls (left, but centered in its space) 19 - HStack( alignment: .center, spacing: 16) { 20 + // Playback controls (left) 21 + HStack(alignment: .center, spacing: 16) { 20 22 Button(action: { player.playPreviousTrack() }) { 21 23 Image(systemName: "backward.fill") 22 24 .font(.system(size: 13)) ··· 66 68 } 67 69 } 68 70 } 69 - .clipShape(RoundedRectangle(cornerRadius: 0)) 70 - 71 + .clipShape(RoundedRectangle(cornerRadius: 4)) 71 72 72 73 VStack(alignment: .leading, spacing: 0) { 73 74 // Track metadata with heart button ··· 84 85 .lineLimit(1) 85 86 } 86 87 87 - // Heart button (shows on hover or when liked) 88 + // Heart button 88 89 Button(action: { 89 90 withAnimation(.easeInOut(duration: 0.2)) { 90 - library.toggleLike(player.currentTrack) } 91 + library.toggleLike(player.currentTrack) 92 + } 91 93 }) { 92 94 Image(systemName: library.isLiked(player.currentTrack) ? "heart.fill" : "heart") 93 95 .font(.system(size: 12)) ··· 111 113 112 114 GeometryReader { geometry in 113 115 ZStack(alignment: .leading) { 114 - // Track background 115 116 Capsule() 116 117 .fill(.quaternary) 117 118 .frame(height: isHoveringProgress ? 6 : 3) 118 119 119 - // Progress fill 120 120 Capsule() 121 121 .fill(.primary.opacity(0.8)) 122 122 .frame(width: geometry.size.width * player.progress, height: isHoveringProgress ? 6 : 3) ··· 156 156 } 157 157 } 158 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) 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 + } 171 179 } 172 - .frame(width: 120) 180 + .frame(width: 60) 173 181 } 174 182 .padding(.horizontal, 16) 175 183 .padding(.vertical, 10) ··· 182 190 return String(format: "%d:%02d", minutes, seconds) 183 191 } 184 192 } 185 -
+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 11 @State private var selection: SidebarItem? = .albums 12 12 @StateObject private var player = PlayerState() 13 13 @StateObject private var library = MusicLibrary() 14 - 14 + @State private var showQueue = false 15 15 16 16 var body: some View { 17 17 NavigationSplitView { 18 18 Sidebar(selection: $selection) 19 19 } detail: { 20 - DetailView(selection: selection, player: player, library: library) 20 + DetailView(selection: selection, player: player, library: library, showQueue: $showQueue) 21 21 } 22 + .inspector(isPresented: $showQueue) { 23 + QueueView() 24 + .inspectorColumnWidth(min: 280, ideal: 300, max: 350) 25 + } 22 26 } 23 27 } 24 28
+3 -1
macos/Rockbox/Views/Main/DetailView.swift
··· 13 13 @ObservedObject var library: MusicLibrary 14 14 @State private var selectedAlbum: Album? = nil 15 15 @State private var selectedArtist: Artist? = nil 16 + @Binding var showQueue: Bool 17 + 16 18 17 19 var body: some View { 18 20 VStack(spacing: 0) { ··· 53 55 Divider() 54 56 55 57 // Player controls 56 - PlayerControlsView(library: library) 58 + PlayerControlsView(library: library, showQueue: $showQueue) 57 59 } 58 60 .frame(maxWidth: .infinity, maxHeight: .infinity) 59 61 .onChange(of: selection) {