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

Add settings service and playback settings handling

Implement SettingsService gRPC helpers: fetchGlobalSettings,
updatePlaylistShuffle, updateRepeatMode.

Extend PlayerState to track shuffle and repeat, add toggle handlers and
fetchSettings, and start fetching settings on app launch.

Add hover states for shuffle and repeat in the controls view.

+411 -259
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+3
macos/Rockbox/RockboxApp.swift
··· 20 .task { 21 player.startStreaming() 22 } 23 } 24 .windowStyle(.hiddenTitleBar) 25 }
··· 20 .task { 21 player.startStreaming() 22 } 23 + .task { 24 + player.fetchSettings() 25 + } 26 } 27 .windowStyle(.hiddenTitleBar) 28 }
+58
macos/Rockbox/Services/SettingsService.swift
···
··· 1 + // 2 + // SettingsService.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 21/12/2025. 6 + // 7 + 8 + import Foundation 9 + import GRPCCore 10 + import GRPCNIOTransportHTTP2 11 + 12 + func fetchGlobalSettings(host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> Rockbox_V1alpha1_GetGlobalSettingsResponse 14 + { 15 + try await withGRPCClient( 16 + transport: .http2NIOPosix( 17 + target: .dns(host: host, port: port), 18 + transportSecurity: .plaintext 19 + ) 20 + ) { grpcClient in 21 + let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 22 + 23 + let req = Rockbox_V1alpha1_GetGlobalSettingsRequest() 24 + let res = try await settings.getGlobalSettings(req) 25 + 26 + return res 27 + } 28 + } 29 + 30 + func updatePlaylistShuffle(enabled: Bool, host: String = "127.0.0.1", port: Int = 6061) async throws { 31 + try await withGRPCClient( 32 + transport: .http2NIOPosix( 33 + target: .dns(host: host, port: port), 34 + transportSecurity: .plaintext 35 + ) 36 + ) { grpcClient in 37 + let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 38 + 39 + var req = Rockbox_V1alpha1_SaveSettingsRequest() 40 + req.playlistShuffle = enabled 41 + let _ = try await settings.saveSettings(req) 42 + } 43 + } 44 + 45 + func updateRepeatMode(repeatMode: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws { 46 + try await withGRPCClient( 47 + transport: .http2NIOPosix( 48 + target: .dns(host: host, port: port), 49 + transportSecurity: .plaintext 50 + ) 51 + ) { grpcClient in 52 + let settings = Rockbox_V1alpha1_SettingsService.Client(wrapping: grpcClient) 53 + 54 + var req = Rockbox_V1alpha1_SaveSettingsRequest() 55 + req.repeatMode = repeatMode 56 + let _ = try await settings.saveSettings(req) 57 + } 58 + }
+323 -256
macos/Rockbox/State/PlayerState.swift
··· 7 import Foundation 8 import SwiftUI 9 10 @MainActor 11 class PlayerState: ObservableObject { 12 - @Published var isPlaying = false 13 - @Published var currentTime: TimeInterval = 0 14 - @Published var duration: TimeInterval = 0 15 - @Published var currentTrack = Song(cuid: "", path: "", title: "Not Playing", artist: "", album: "", albumArt: nil, duration: TimeInterval(0), trackNumber: 0, discNumber: 0, albumID: "", artistID: "", 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 - if currentIndex == 0 && queue.count > 0 { 41 - return Array(queue[...currentIndex]) 42 } 43 - guard currentIndex > 0 else { return [] } 44 - return Array(queue[...currentIndex]) 45 } 46 - 47 - func startStreaming() { 48 - getCurrentTrack() 49 - streamTask?.cancel() 50 - streamTask = Task { 51 - do { 52 - isConnected = true 53 - for try await response in currentTrackStream() { 54 - self.currentTrack = Song( 55 - cuid: response.id, 56 - path: response.path, 57 - title: response.title, 58 - artist: response.artist, 59 - album: response.album, 60 - albumArt: URL(string: "http://localhost:6062/covers/" + response.albumArt), 61 - duration: TimeInterval(response.length / 1000), 62 - trackNumber: Int(response.tracknum), 63 - discNumber: Int(response.discnum), 64 - albumID: response.albumID, 65 - artistID: response.artistID, 66 - color: .gray.opacity(0.3) 67 - ) 68 - self.duration = TimeInterval(response.length / 1000) 69 - self.currentTime = TimeInterval(response.elapsed / 1000) 70 - } 71 - // Refresh queue when track changes 72 - await self.fetchQueue() 73 74 - } catch is CancellationError { 75 - // Ignored 76 - } catch { 77 - self.error = error 78 - } 79 - isConnected = false 80 } 81 - 82 - streamStatusTask?.cancel() 83 - streamStatusTask = Task { 84 - do { 85 - for try await response in playbackStatusStream() { 86 - self.isPlaying = response.status == 1 87 - self.status = response.status 88 - } 89 - } catch is CancellationError { 90 - // Ignored 91 - } catch { 92 - self.error = error 93 - } 94 } 95 - 96 - streamPlaylistTask?.cancel() 97 - streamPlaylistTask = Task { 98 - do { 99 - for try await data in currentPlaylistStream() { 100 - if self.currentIndex == Int(data.index) { continue } 101 - self.currentIndex = Int(data.index) 102 - self.playlistLength = Int(data.amount) 103 - self.queue = data.tracks.map { track in 104 - Song( 105 - cuid: track.id, 106 - path: track.path, 107 - title: track.title, 108 - artist: track.artist, 109 - album: track.album, 110 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 111 - duration: TimeInterval(track.length / 1000), 112 - trackNumber: Int(track.tracknum), 113 - discNumber: Int(track.discnum), 114 - albumID: track.albumID, 115 - artistID: track.artistID, 116 - color: .gray.opacity(0.3) 117 - ) 118 - } 119 - 120 - self.currentTrack = self.queue[self.currentIndex] 121 - } 122 - } catch is CancellationError { 123 - // Ignored 124 - } catch { 125 - self.error = error 126 - } 127 } 128 } 129 - 130 - func stopStreaming() { 131 - streamTask?.cancel() 132 - streamTask = nil 133 - streamStatusTask?.cancel() 134 - streamStatusTask = nil 135 } 136 - 137 - func getCurrentTrack() { 138 - Task { 139 - do { 140 - let data = try await fetchCurrentPlaylist() 141 - if data.tracks.count > 0 { 142 - let index = Int(data.index) 143 - self.currentIndex = index 144 - self.playlistLength = Int(data.amount) 145 - 146 - self.queue = data.tracks.map { track in 147 - Song( 148 - cuid: track.id, 149 - path: track.path, 150 - title: track.title, 151 - artist: track.artist, 152 - album: track.album, 153 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 154 - duration: TimeInterval(track.length / 1000), 155 - trackNumber: Int(track.tracknum), 156 - discNumber: Int(track.discnum), 157 - albumID: track.albumID, 158 - artistID: track.artistID, 159 - color: .gray.opacity(0.3) 160 - ) 161 - } 162 - 163 - self.currentTrack = self.queue[index] 164 - 165 - let globalStatus = try await fetchGlobalStatus() 166 - self.currentTime = TimeInterval(globalStatus.resumeElapsed / 1000) 167 - self.duration = TimeInterval(data.tracks[index].length / 1000) 168 - } 169 - } catch { 170 - self.error = error 171 - } 172 } 173 } 174 - 175 - func fetchQueue() async { 176 - do { 177 - let data = try await fetchCurrentPlaylist() 178 - if data.tracks.count > 0 { 179 - self.currentIndex = Int(data.index) 180 - self.playlistLength = Int(data.amount) 181 - self.queue = data.tracks.map { track in 182 - Song( 183 - cuid: track.id, 184 - path: track.path, 185 - title: track.title, 186 - artist: track.artist, 187 - album: track.album, 188 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 189 - duration: TimeInterval(track.length / 1000), 190 - trackNumber: Int(track.tracknum), 191 - discNumber: Int(track.discnum), 192 - albumID: track.albumID, 193 - artistID: track.artistID, 194 - color: .gray.opacity(0.3) 195 - ) 196 - } 197 - } 198 - } catch { 199 - self.error = error 200 - } 201 } 202 - 203 - func playPreviousTrack() { 204 - Task { 205 - do { 206 - try await previous() 207 - } catch { 208 - self.error = error 209 - } 210 - } 211 } 212 - 213 - func playNextTrack() { 214 - Task { 215 - do { 216 - try await next() 217 - } catch { 218 - self.error = error 219 - } 220 - } 221 } 222 - 223 - func playOrPause() { 224 - Task { 225 - do { 226 - let globalStatus = try await fetchGlobalStatus() 227 - if globalStatus.resumeIndex > -1 && status == 0 { 228 - try await resumeTrack() 229 - return 230 - } 231 - 232 - if isPlaying { 233 - try await pause() 234 - return 235 - } 236 - 237 - try await resume() 238 - } catch { 239 - self.error = error 240 - } 241 - } 242 } 243 - 244 - func seek(position: Int64) { 245 - self.currentTime = TimeInterval(position / 1000) 246 - Task { 247 - do { 248 - try await play(elapsed: position) 249 - } catch { 250 - self.error = error 251 - } 252 - } 253 } 254 - 255 - func playFromQueue(at index: Int) { 256 - Task { 257 - do { 258 - try await startPlaylist(position: Int32(index)) 259 - self.currentIndex = index 260 - self.currentTrack = queue[index] 261 - } catch { 262 - self.error = error 263 - } 264 - } 265 } 266 - 267 - func removeFromQueue(at index: Int) { 268 - Task { 269 - do { 270 - try await removeFromPlaylist(position: Int32(index)) 271 - await fetchQueue() 272 - } catch { 273 - self.error = error 274 - } 275 - } 276 } 277 278 - func clearQueue() { 279 - Task { 280 - do { 281 - try await clearPlaylist() 282 - await fetchQueue() 283 - } catch { 284 - self.error = error 285 - } 286 - } 287 - } 288 }
··· 7 import Foundation 8 import SwiftUI 9 10 + enum RepeatMode { 11 + case off 12 + case all 13 + case one 14 + } 15 + 16 @MainActor 17 class PlayerState: ObservableObject { 18 + @Published var isPlaying = false 19 + @Published var currentTime: TimeInterval = 0 20 + @Published var duration: TimeInterval = 0 21 + @Published var currentTrack = Song( 22 + cuid: "", path: "", title: "Not Playing", artist: "", album: "", albumArt: nil, 23 + duration: TimeInterval(0), trackNumber: 0, discNumber: 0, albumID: "", artistID: "", 24 + color: .gray.opacity(0.3)) 25 + @Published var queue: [Song] = [] 26 + @Published var currentIndex: Int = 0 27 + @Published var playlistLength: Int = 0 28 + @Published var isConnected = false 29 + @Published var error: Error? 30 + @Published var status: Int32 = 0 31 + @Published var isShuffleEnabled: Bool = false 32 + @Published var repeatMode: RepeatMode = .off 33 + 34 + private var streamTask: Task<Void, Never>? 35 + private var streamStatusTask: Task<Void, Never>? 36 + private var streamPlaylistTask: Task<Void, Never>? 37 + 38 + var progress: Double { 39 + get { duration > 0 ? currentTime / duration : 0 } 40 + set { currentTime = newValue * duration } 41 + } 42 + 43 + // Upcoming tracks (after current) 44 + var upNext: [Song] { 45 + guard currentIndex + 1 < queue.count else { return [] } 46 + return Array(queue[(currentIndex + 1)...]) 47 + } 48 + 49 + // Previous tracks (before current + current) 50 + var history: [Song] { 51 + if currentIndex == 0 && queue.count > 0 { 52 + return Array(queue[...currentIndex]) 53 } 54 + guard currentIndex > 0 else { return [] } 55 + return Array(queue[...currentIndex]) 56 + } 57 + 58 + func startStreaming() { 59 + getCurrentTrack() 60 + streamTask?.cancel() 61 + streamTask = Task { 62 + do { 63 + isConnected = true 64 + for try await response in currentTrackStream() { 65 + self.currentTrack = Song( 66 + cuid: response.id, 67 + path: response.path, 68 + title: response.title, 69 + artist: response.artist, 70 + album: response.album, 71 + albumArt: URL(string: "http://localhost:6062/covers/" + response.albumArt), 72 + duration: TimeInterval(response.length / 1000), 73 + trackNumber: Int(response.tracknum), 74 + discNumber: Int(response.discnum), 75 + albumID: response.albumID, 76 + artistID: response.artistID, 77 + color: .gray.opacity(0.3) 78 + ) 79 + self.duration = TimeInterval(response.length / 1000) 80 + self.currentTime = TimeInterval(response.elapsed / 1000) 81 + } 82 + // Refresh queue when track changes 83 + await self.fetchQueue() 84 + 85 + } catch is CancellationError { 86 + // Ignored 87 + } catch { 88 + self.error = error 89 + } 90 + isConnected = false 91 } 92 + 93 + streamStatusTask?.cancel() 94 + streamStatusTask = Task { 95 + do { 96 + for try await response in playbackStatusStream() { 97 + self.isPlaying = response.status == 1 98 + self.status = response.status 99 } 100 + } catch is CancellationError { 101 + // Ignored 102 + } catch { 103 + self.error = error 104 + } 105 } 106 + 107 + streamPlaylistTask?.cancel() 108 + streamPlaylistTask = Task { 109 + do { 110 + for try await data in currentPlaylistStream() { 111 + if self.currentIndex == Int(data.index) { continue } 112 + self.currentIndex = Int(data.index) 113 + self.playlistLength = Int(data.amount) 114 + self.queue = data.tracks.map { track in 115 + Song( 116 + cuid: track.id, 117 + path: track.path, 118 + title: track.title, 119 + artist: track.artist, 120 + album: track.album, 121 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 122 + duration: TimeInterval(track.length / 1000), 123 + trackNumber: Int(track.tracknum), 124 + discNumber: Int(track.discnum), 125 + albumID: track.albumID, 126 + artistID: track.artistID, 127 + color: .gray.opacity(0.3) 128 + ) 129 + } 130 131 + self.currentTrack = self.queue[self.currentIndex] 132 } 133 + } catch is CancellationError { 134 + // Ignored 135 + } catch { 136 + self.error = error 137 + } 138 + } 139 + } 140 + 141 + func stopStreaming() { 142 + streamTask?.cancel() 143 + streamTask = nil 144 + streamStatusTask?.cancel() 145 + streamStatusTask = nil 146 + } 147 + 148 + func getCurrentTrack() { 149 + Task { 150 + do { 151 + let data = try await fetchCurrentPlaylist() 152 + if data.tracks.count > 0 { 153 + let index = Int(data.index) 154 + self.currentIndex = index 155 + self.playlistLength = Int(data.amount) 156 + 157 + self.queue = data.tracks.map { track in 158 + Song( 159 + cuid: track.id, 160 + path: track.path, 161 + title: track.title, 162 + artist: track.artist, 163 + album: track.album, 164 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 165 + duration: TimeInterval(track.length / 1000), 166 + trackNumber: Int(track.tracknum), 167 + discNumber: Int(track.discnum), 168 + albumID: track.albumID, 169 + artistID: track.artistID, 170 + color: .gray.opacity(0.3) 171 + ) 172 + } 173 + 174 + self.currentTrack = self.queue[index] 175 + 176 + let globalStatus = try await fetchGlobalStatus() 177 + self.currentTime = TimeInterval(globalStatus.resumeElapsed / 1000) 178 + self.duration = TimeInterval(data.tracks[index].length / 1000) 179 } 180 + } catch { 181 + self.error = error 182 + } 183 + } 184 + } 185 + 186 + func fetchQueue() async { 187 + do { 188 + let data = try await fetchCurrentPlaylist() 189 + if data.tracks.count > 0 { 190 + self.currentIndex = Int(data.index) 191 + self.playlistLength = Int(data.amount) 192 + self.queue = data.tracks.map { track in 193 + Song( 194 + cuid: track.id, 195 + path: track.path, 196 + title: track.title, 197 + artist: track.artist, 198 + album: track.album, 199 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 200 + duration: TimeInterval(track.length / 1000), 201 + trackNumber: Int(track.tracknum), 202 + discNumber: Int(track.discnum), 203 + albumID: track.albumID, 204 + artistID: track.artistID, 205 + color: .gray.opacity(0.3) 206 + ) 207 } 208 + } 209 + } catch { 210 + self.error = error 211 + } 212 + } 213 + 214 + func playPreviousTrack() { 215 + Task { 216 + do { 217 + try await previous() 218 + } catch { 219 + self.error = error 220 + } 221 } 222 + } 223 + 224 + func playNextTrack() { 225 + Task { 226 + do { 227 + try await next() 228 + } catch { 229 + self.error = error 230 + } 231 } 232 + } 233 + 234 + func playOrPause() { 235 + Task { 236 + do { 237 + let globalStatus = try await fetchGlobalStatus() 238 + if globalStatus.resumeIndex > -1 && status == 0 { 239 + try await resumeTrack() 240 + return 241 + } 242 + 243 + if isPlaying { 244 + try await pause() 245 + return 246 } 247 + 248 + try await resume() 249 + } catch { 250 + self.error = error 251 + } 252 } 253 + } 254 + 255 + func seek(position: Int64) { 256 + self.currentTime = TimeInterval(position / 1000) 257 + Task { 258 + do { 259 + try await play(elapsed: position) 260 + } catch { 261 + self.error = error 262 + } 263 } 264 + } 265 + 266 + func playFromQueue(at index: Int) { 267 + Task { 268 + do { 269 + try await startPlaylist(position: Int32(index)) 270 + self.currentIndex = index 271 + self.currentTrack = queue[index] 272 + } catch { 273 + self.error = error 274 + } 275 } 276 + } 277 + 278 + func removeFromQueue(at index: Int) { 279 + Task { 280 + do { 281 + try await removeFromPlaylist(position: Int32(index)) 282 + await fetchQueue() 283 + } catch { 284 + self.error = error 285 + } 286 } 287 + } 288 + 289 + func clearQueue() { 290 + Task { 291 + do { 292 + try await clearPlaylist() 293 + await fetchQueue() 294 + } catch { 295 + self.error = error 296 + } 297 } 298 + } 299 + 300 + func toggleShuffle() { 301 + isShuffleEnabled.toggle() 302 + Task { 303 + do { 304 + try await updatePlaylistShuffle(enabled: isShuffleEnabled) 305 + fetchSettings() 306 + await fetchQueue() 307 + } catch { 308 + self.error = error 309 + isShuffleEnabled.toggle() 310 + } 311 } 312 + } 313 + 314 + func toggleRepeat() { 315 + var mode: Int32 = 0 316 + switch repeatMode { 317 + case .off: 318 + mode = 2 319 + case .all: 320 + mode = 1 321 + case .one: 322 + mode = 0 323 } 324 + 325 + Task { 326 + do { 327 + try await updateRepeatMode(repeatMode: mode) 328 + fetchSettings() 329 + } catch { 330 + self.error = error 331 + } 332 } 333 + } 334 335 + func fetchSettings() { 336 + Task { 337 + do { 338 + let data = try await fetchGlobalSettings() 339 + switch data.repeatMode { 340 + case 0: 341 + repeatMode = .off 342 + case 1: 343 + repeatMode = .one 344 + case 2: 345 + repeatMode = .all 346 + default: 347 + repeatMode = .off 348 + } 349 + isShuffleEnabled = data.playlistShuffle 350 + } catch { 351 + self.error = error 352 + } 353 + } 354 + } 355 }
+27 -3
macos/Rockbox/Views/Components/PlayerControlsView.swift
··· 14 @State private var isHoveringTrackInfo = false 15 @State private var isHoveringQueue = false 16 @State private var isHoveringMenu = false 17 @State private var errorText: String? = nil 18 @ObservedObject var library: MusicLibrary 19 @Binding var showQueue: Bool // Add this binding ··· 23 HStack(spacing: 0) { 24 // Playback controls (left) 25 HStack(alignment: .center, spacing: 16) { 26 Button(action: { player.playPreviousTrack() }) { 27 Image(systemName: "backward.fill") 28 - .font(.system(size: 13)) 29 } 30 .buttonStyle(.plain) 31 ··· 33 player.playOrPause() 34 }) { 35 Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") 36 - .font(.system(size: 16)) 37 } 38 .buttonStyle(.plain) 39 40 Button(action: { player.playNextTrack() }) { 41 Image(systemName: "forward.fill") 42 - .font(.system(size: 13)) 43 } 44 .buttonStyle(.plain) 45 } 46 .foregroundStyle(.primary) 47 .frame(maxWidth: 280)
··· 14 @State private var isHoveringTrackInfo = false 15 @State private var isHoveringQueue = false 16 @State private var isHoveringMenu = false 17 + @State private var isHoveringShuffle = false 18 + @State private var isHoveringRepeat = false 19 @State private var errorText: String? = nil 20 @ObservedObject var library: MusicLibrary 21 @Binding var showQueue: Bool // Add this binding ··· 25 HStack(spacing: 0) { 26 // Playback controls (left) 27 HStack(alignment: .center, spacing: 16) { 28 + Button(action: { 29 + player.toggleShuffle() 30 + }) { 31 + Image(systemName: "shuffle") 32 + .font(.system(size: 14)) 33 + .foregroundStyle(player.isShuffleEnabled ? Color(hex: "fe09a3") : (isHoveringShuffle ? .primary : .secondary)) 34 + } 35 + .buttonStyle(.plain) 36 + .onHover { isHoveringShuffle = $0 } 37 + 38 Button(action: { player.playPreviousTrack() }) { 39 Image(systemName: "backward.fill") 40 + .font(.system(size: 16)) 41 } 42 .buttonStyle(.plain) 43 ··· 45 player.playOrPause() 46 }) { 47 Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") 48 + .font(.system(size: 24)) 49 } 50 .buttonStyle(.plain) 51 52 Button(action: { player.playNextTrack() }) { 53 Image(systemName: "forward.fill") 54 + .font(.system(size: 16)) 55 + } 56 + .buttonStyle(.plain) 57 + 58 + Button(action: { 59 + player.toggleRepeat() 60 + }) { 61 + ZStack { 62 + Image(systemName: player.repeatMode == .one ? "repeat.1" : "repeat") 63 + .font(.system(size: 14)) 64 + .foregroundStyle(player.repeatMode != .off ? Color(hex: "fe09a3") : (isHoveringRepeat ? .primary : .secondary)) 65 + } 66 } 67 .buttonStyle(.plain) 68 + .onHover { isHoveringRepeat = $0 } 69 } 70 .foregroundStyle(.primary) 71 .frame(maxWidth: 280)