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 20 .task { 21 21 player.startStreaming() 22 22 } 23 + .task { 24 + player.fetchSettings() 25 + } 23 26 } 24 27 .windowStyle(.hiddenTitleBar) 25 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 7 import Foundation 8 8 import SwiftUI 9 9 10 + enum RepeatMode { 11 + case off 12 + case all 13 + case one 14 + } 15 + 10 16 @MainActor 11 17 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 } 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]) 30 53 } 31 - 32 - // Upcoming tracks (after current) 33 - var upNext: [Song] { 34 - guard currentIndex + 1 < queue.count else { return [] } 35 - return Array(queue[(currentIndex + 1)...]) 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 36 91 } 37 - 38 - // Previous tracks (before current + current) 39 - var history: [Song] { 40 - if currentIndex == 0 && queue.count > 0 { 41 - return Array(queue[...currentIndex]) 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 42 99 } 43 - guard currentIndex > 0 else { return [] } 44 - return Array(queue[...currentIndex]) 100 + } catch is CancellationError { 101 + // Ignored 102 + } catch { 103 + self.error = error 104 + } 45 105 } 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() 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 + } 73 130 74 - } catch is CancellationError { 75 - // Ignored 76 - } catch { 77 - self.error = error 78 - } 79 - isConnected = false 131 + self.currentTrack = self.queue[self.currentIndex] 80 132 } 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 - } 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) 94 179 } 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 - } 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 + ) 127 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 + } 128 221 } 129 - 130 - func stopStreaming() { 131 - streamTask?.cancel() 132 - streamTask = nil 133 - streamStatusTask?.cancel() 134 - streamStatusTask = nil 222 + } 223 + 224 + func playNextTrack() { 225 + Task { 226 + do { 227 + try await next() 228 + } catch { 229 + self.error = error 230 + } 135 231 } 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 - } 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 172 246 } 247 + 248 + try await resume() 249 + } catch { 250 + self.error = error 251 + } 173 252 } 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 - } 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 + } 201 263 } 202 - 203 - func playPreviousTrack() { 204 - Task { 205 - do { 206 - try await previous() 207 - } catch { 208 - self.error = error 209 - } 210 - } 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 + } 211 275 } 212 - 213 - func playNextTrack() { 214 - Task { 215 - do { 216 - try await next() 217 - } catch { 218 - self.error = error 219 - } 220 - } 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 + } 221 286 } 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 - } 287 + } 288 + 289 + func clearQueue() { 290 + Task { 291 + do { 292 + try await clearPlaylist() 293 + await fetchQueue() 294 + } catch { 295 + self.error = error 296 + } 242 297 } 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 - } 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 + } 253 311 } 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 - } 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 265 323 } 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 - } 324 + 325 + Task { 326 + do { 327 + try await updateRepeatMode(repeatMode: mode) 328 + fetchSettings() 329 + } catch { 330 + self.error = error 331 + } 276 332 } 333 + } 277 334 278 - func clearQueue() { 279 - Task { 280 - do { 281 - try await clearPlaylist() 282 - await fetchQueue() 283 - } catch { 284 - self.error = error 285 - } 286 - } 287 - } 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 + } 288 355 }
+27 -3
macos/Rockbox/Views/Components/PlayerControlsView.swift
··· 14 14 @State private var isHoveringTrackInfo = false 15 15 @State private var isHoveringQueue = false 16 16 @State private var isHoveringMenu = false 17 + @State private var isHoveringShuffle = false 18 + @State private var isHoveringRepeat = false 17 19 @State private var errorText: String? = nil 18 20 @ObservedObject var library: MusicLibrary 19 21 @Binding var showQueue: Bool // Add this binding ··· 23 25 HStack(spacing: 0) { 24 26 // Playback controls (left) 25 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 + 26 38 Button(action: { player.playPreviousTrack() }) { 27 39 Image(systemName: "backward.fill") 28 - .font(.system(size: 13)) 40 + .font(.system(size: 16)) 29 41 } 30 42 .buttonStyle(.plain) 31 43 ··· 33 45 player.playOrPause() 34 46 }) { 35 47 Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") 36 - .font(.system(size: 16)) 48 + .font(.system(size: 24)) 37 49 } 38 50 .buttonStyle(.plain) 39 51 40 52 Button(action: { player.playNextTrack() }) { 41 53 Image(systemName: "forward.fill") 42 - .font(.system(size: 13)) 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 + } 43 66 } 44 67 .buttonStyle(.plain) 68 + .onHover { isHoveringRepeat = $0 } 45 69 } 46 70 .foregroundStyle(.primary) 47 71 .frame(maxWidth: 280)