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

Add Song.path, Playlist model and UI menu

+1308 -330
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+16
macos/Rockbox/Models/Core/Playlist.swift
··· 1 + // 2 + // Playlist.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 21/12/2025. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct Playlist: Identifiable { 11 + let id = UUID() 12 + let cuid: String 13 + let name: String 14 + let description: String? 15 + let tracks: [Song] 16 + }
+1
macos/Rockbox/Models/Core/Song.swift
··· 10 10 struct Song: Identifiable { 11 11 let id = UUID() 12 12 let cuid: String 13 + let path: String 13 14 let title: String 14 15 let artist: String 15 16 let album: String
+17
macos/Rockbox/Models/Enums/PlaylistPosition.swift
··· 1 + // 2 + // PlaylistPosition.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 21/12/2025. 6 + // 7 + 8 + struct PlaylistPosition { 9 + static let prepend = -1 10 + static let insert = -2 11 + static let insertLast = -3 12 + static let insertFirst = -4 13 + static let insertShuffled = -5 14 + static let replace = -6 15 + static let insertLastShuffled = -7 16 + static let insertLastRotated = -8 17 + }
+22 -22
macos/Rockbox/Samples.swift
··· 126 126 127 127 128 128 let sampleSongs: [Song] = [ 129 - Song(cuid: "", title: "Bohemian Rhapsody", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 354, trackNumber: 1, discNumber: 1, color: .purple), 130 - Song(cuid: "", title: "You're My Best Friend", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 170, trackNumber: 1, discNumber: 1, color: .purple), 131 - Song(cuid: "", title: "Love of My Life", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 219, trackNumber: 1, discNumber: 1, color: .purple), 132 - Song(cuid: "", title: "The Chain", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 271,trackNumber: 1, discNumber: 1, color: .blue), 133 - Song(cuid: "", title: "Dreams", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 257,trackNumber: 1, discNumber: 1, color: .blue), 134 - Song(cuid: "", title: "Go Your Own Way", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 222, trackNumber: 1, discNumber: 1, color: .blue), 135 - Song(cuid: "", title: "Back in Black", artist: "AC/DC", album: "Back in Black", albumArt: nil, duration: 255,trackNumber: 1, discNumber: 1, color: .black), 136 - Song(cuid: "", title: "Hells Bells", artist: "AC/DC", album: "Back in Black", albumArt: nil, duration: 312,trackNumber: 1, discNumber: 1, color: .black), 137 - Song(cuid: "", title: "Thriller", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 357,trackNumber: 1, discNumber: 1, color: .orange), 138 - Song(cuid: "", title: "Billie Jean", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 294, trackNumber: 1, discNumber: 1, color: .orange), 139 - Song(cuid: "", title: "Beat It", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 258,trackNumber: 1, discNumber: 1, color: .orange), 140 - Song(cuid: "", title: "Time", artist: "Pink Floyd", album: "The Dark Side of the Moon", albumArt: nil, duration: 413, trackNumber: 1, discNumber: 1, color: .indigo), 141 - Song(cuid: "", title: "Money", artist: "Pink Floyd", album: "The Dark Side of the Moon", albumArt: nil, duration: 382, trackNumber: 1, discNumber: 1, color: .indigo), 142 - Song(cuid: "", title: "Come Together", artist: "The Beatles", album: "Abbey Road", albumArt: nil, duration: 259,trackNumber: 1, discNumber: 1, color: .cyan), 143 - Song(cuid: "", title: "Here Comes the Sun", artist: "The Beatles", album: "Abbey Road", albumArt: nil, duration: 185, trackNumber: 1, discNumber: 1, color: .cyan), 144 - Song(cuid: "", title: "Stairway to Heaven", artist: "Led Zeppelin", album: "Led Zeppelin IV", albumArt: nil, duration: 482,trackNumber: 1, discNumber: 1, color: .brown), 145 - Song(cuid: "", title: "Rock and Roll", artist: "Led Zeppelin", album: "Led Zeppelin IV", albumArt: nil, duration: 220, trackNumber: 1, discNumber: 1, color: .brown), 146 - Song(cuid: "", title: "Hotel California", artist: "Eagles", album: "Hotel California", albumArt: nil, duration: 391, trackNumber: 1, discNumber: 1, color: .yellow), 147 - Song(cuid: "", title: "Born to Run", artist: "Bruce Springsteen", album: "Born to Run", albumArt: nil, duration: 270, trackNumber: 1, discNumber: 1, color: .red), 148 - Song(cuid: "", title: "Purple Rain", artist: "Prince", album: "Purple Rain", albumArt: nil, duration: 520,trackNumber: 1, discNumber: 1, color: .purple), 149 - Song(cuid: "", title: "With or Without You", artist: "U2", album: "The Joshua Tree", albumArt: nil, duration: 296, trackNumber: 1, discNumber: 1, color: .gray), 150 - Song(cuid: "", title: "Smells Like Teen Spirit", artist: "Nirvana", album: "Nevermind", albumArt: nil, duration: 301, trackNumber: 1, discNumber: 1, color: .teal), 129 + Song(cuid: "", path: "", title: "Bohemian Rhapsody", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 354, trackNumber: 1, discNumber: 1, color: .purple), 130 + Song(cuid: "", path: "", title: "You're My Best Friend", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 170, trackNumber: 1, discNumber: 1, color: .purple), 131 + Song(cuid: "", path: "", title: "Love of My Life", artist: "Queen", album: "A Night at the Opera", albumArt: nil, duration: 219, trackNumber: 1, discNumber: 1, color: .purple), 132 + Song(cuid: "", path: "", title: "The Chain", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 271,trackNumber: 1, discNumber: 1, color: .blue), 133 + Song(cuid: "", path: "",title: "Dreams", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 257,trackNumber: 1, discNumber: 1, color: .blue), 134 + Song(cuid: "", path: "", title: "Go Your Own Way", artist: "Fleetwood Mac", album: "Rumours", albumArt: nil, duration: 222, trackNumber: 1, discNumber: 1, color: .blue), 135 + Song(cuid: "", path: "",title: "Back in Black", artist: "AC/DC", album: "Back in Black", albumArt: nil, duration: 255,trackNumber: 1, discNumber: 1, color: .black), 136 + Song(cuid: "", path: "", title: "Hells Bells", artist: "AC/DC", album: "Back in Black", albumArt: nil, duration: 312,trackNumber: 1, discNumber: 1, color: .black), 137 + Song(cuid: "", path: "", title: "Thriller", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 357,trackNumber: 1, discNumber: 1, color: .orange), 138 + Song(cuid: "", path: "", title: "Billie Jean", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 294, trackNumber: 1, discNumber: 1, color: .orange), 139 + Song(cuid: "", path: "", title: "Beat It", artist: "Michael Jackson", album: "Thriller", albumArt: nil, duration: 258,trackNumber: 1, discNumber: 1, color: .orange), 140 + Song(cuid: "", path: "", title: "Time", artist: "Pink Floyd", album: "The Dark Side of the Moon", albumArt: nil, duration: 413, trackNumber: 1, discNumber: 1, color: .indigo), 141 + Song(cuid: "", path: "", title: "Money", artist: "Pink Floyd", album: "The Dark Side of the Moon", albumArt: nil, duration: 382, trackNumber: 1, discNumber: 1, color: .indigo), 142 + Song(cuid: "", path: "", title: "Come Together", artist: "The Beatles", album: "Abbey Road", albumArt: nil, duration: 259,trackNumber: 1, discNumber: 1, color: .cyan), 143 + Song(cuid: "", path: "", title: "Here Comes the Sun", artist: "The Beatles", album: "Abbey Road", albumArt: nil, duration: 185, trackNumber: 1, discNumber: 1, color: .cyan), 144 + Song(cuid: "", path: "", title: "Stairway to Heaven", artist: "Led Zeppelin", album: "Led Zeppelin IV", albumArt: nil, duration: 482,trackNumber: 1, discNumber: 1, color: .brown), 145 + Song(cuid: "", path: "", title: "Rock and Roll", artist: "Led Zeppelin", album: "Led Zeppelin IV", albumArt: nil, duration: 220, trackNumber: 1, discNumber: 1, color: .brown), 146 + Song(cuid: "", path: "", title: "Hotel California", artist: "Eagles", album: "Hotel California", albumArt: nil, duration: 391, trackNumber: 1, discNumber: 1, color: .yellow), 147 + Song(cuid: "", path: "", title: "Born to Run", artist: "Bruce Springsteen", album: "Born to Run", albumArt: nil, duration: 270, trackNumber: 1, discNumber: 1, color: .red), 148 + Song(cuid: "", path: "", title: "Purple Rain", artist: "Prince", album: "Purple Rain", albumArt: nil, duration: 520,trackNumber: 1, discNumber: 1, color: .purple), 149 + Song(cuid: "", path: "", title: "With or Without You", artist: "U2", album: "The Joshua Tree", albumArt: nil, duration: 296, trackNumber: 1, discNumber: 1, color: .gray), 150 + Song(cuid: "", path: "", title: "Smells Like Teen Spirit", artist: "Nirvana", album: "Nevermind", albumArt: nil, duration: 301, trackNumber: 1, discNumber: 1, color: .teal), 151 151 ]
+8 -4
macos/Rockbox/Services/AlbumService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchAlbums(host: String = "127.0.0.1", port: Int = 6061) async throws -> [Rockbox_V1alpha1_Album] { 12 + func fetchAlbums(host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> [Rockbox_V1alpha1_Album] 14 + { 13 15 try await withGRPCClient( 14 16 transport: .http2NIOPosix( 15 17 target: .dns(host: host, port: port), ··· 26 28 } 27 29 } 28 30 29 - func fetchAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Rockbox_V1alpha1_Album { 31 + func fetchAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 32 + -> Rockbox_V1alpha1_Album 33 + { 30 34 try await withGRPCClient( 31 35 transport: .http2NIOPosix( 32 36 target: .dns(host: host, port: port), ··· 44 48 } 45 49 } 46 50 47 - func likeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 51 + func likeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 48 52 try await withGRPCClient( 49 53 transport: .http2NIOPosix( 50 54 target: .dns(host: host, port: port), ··· 59 63 } 60 64 } 61 65 62 - func unlikeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 66 + func unlikeAlbum(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 63 67 try await withGRPCClient( 64 68 transport: .http2NIOPosix( 65 69 target: .dns(host: host, port: port),
+6 -3
macos/Rockbox/Services/ArtistService.swift
··· 5 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 6 // 7 7 8 - 9 8 import Foundation 10 9 import GRPCCore 11 10 import GRPCNIOTransportHTTP2 12 11 13 - func fetchArtists(host: String = "127.0.0.1", port: Int = 6061) async throws -> [Rockbox_V1alpha1_Artist] { 12 + func fetchArtists(host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> [Rockbox_V1alpha1_Artist] 14 + { 14 15 try await withGRPCClient( 15 16 transport: .http2NIOPosix( 16 17 target: .dns(host: host, port: port), ··· 25 26 } 26 27 } 27 28 28 - func fetchArtist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Rockbox_V1alpha1_Artist { 29 + func fetchArtist(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws 30 + -> Rockbox_V1alpha1_Artist 31 + { 29 32 try await withGRPCClient( 30 33 transport: .http2NIOPosix( 31 34 target: .dns(host: host, port: port),
+4 -3
macos/Rockbox/Services/BrowseService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - 13 - func fetchFiles(path: String?, host: String = "127.0.0.1", port: Int = 6061) async throws -> [Rockbox_V1alpha1_Entry] { 12 + func fetchFiles(path: String?, host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> [Rockbox_V1alpha1_Entry] 14 + { 14 15 try await withGRPCClient( 15 16 transport: .http2NIOPosix( 16 17 target: .dns(host: host, port: port), ··· 24 25 req.path = path ?? String() 25 26 } 26 27 let res = try await browse.treeGetEntries(req) 27 - return res.entries 28 + return res.entries 28 29 } 29 30 }
+121 -110
macos/Rockbox/Services/PlaybackService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func play(elapsed: Int64, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 12 + func play(elapsed: Int64, host: String = "127.0.0.1", port: Int = 6061) async throws { 13 13 try await withGRPCClient( 14 14 transport: .http2NIOPosix( 15 15 target: .dns(host: host, port: port), 16 16 transportSecurity: .plaintext 17 17 ) 18 18 ) { grpcClient in 19 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 20 - var req = Rockbox_V1alpha1_PlayRequest() 21 - req.elapsed = elapsed 22 - req.offset = 0 23 - let _ = try await playback.play(req) 19 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 20 + var req = Rockbox_V1alpha1_PlayRequest() 21 + req.elapsed = elapsed 22 + req.offset = 0 23 + let _ = try await playback.play(req) 24 24 } 25 25 } 26 26 27 - func resume(host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 27 + func resume(host: String = "127.0.0.1", port: Int = 6061) async throws { 28 28 try await withGRPCClient( 29 29 transport: .http2NIOPosix( 30 30 target: .dns(host: host, port: port), 31 31 transportSecurity: .plaintext 32 32 ) 33 33 ) { grpcClient in 34 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 35 - let req = Rockbox_V1alpha1_ResumeRequest() 36 - let _ = try await playback.resume(req) 34 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 35 + let req = Rockbox_V1alpha1_ResumeRequest() 36 + let _ = try await playback.resume(req) 37 37 } 38 38 } 39 39 40 - func pause(host: String = "127.0.0.1", port: Int = 6061) async throws -> Void{ 40 + func pause(host: String = "127.0.0.1", port: Int = 6061) async throws { 41 41 try await withGRPCClient( 42 42 transport: .http2NIOPosix( 43 43 target: .dns(host: host, port: port), 44 44 transportSecurity: .plaintext 45 45 ) 46 46 ) { grpcClient in 47 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 48 - let req = Rockbox_V1alpha1_PauseRequest() 49 - let _ = try await playback.pause(req) 47 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 48 + let req = Rockbox_V1alpha1_PauseRequest() 49 + let _ = try await playback.pause(req) 50 50 } 51 51 } 52 52 53 - func previous(host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 53 + func previous(host: String = "127.0.0.1", port: Int = 6061) async throws { 54 54 try await withGRPCClient( 55 55 transport: .http2NIOPosix( 56 56 target: .dns(host: host, port: port), 57 57 transportSecurity: .plaintext 58 58 ) 59 59 ) { grpcClient in 60 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 61 - let req = Rockbox_V1alpha1_PreviousRequest() 62 - let _ = try await playback.previous(req) 60 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 61 + let req = Rockbox_V1alpha1_PreviousRequest() 62 + let _ = try await playback.previous(req) 63 63 } 64 64 } 65 65 66 - func next(host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 66 + func next(host: String = "127.0.0.1", port: Int = 6061) async throws { 67 67 try await withGRPCClient( 68 68 transport: .http2NIOPosix( 69 69 target: .dns(host: host, port: port), 70 70 transportSecurity: .plaintext 71 71 ) 72 72 ) { grpcClient in 73 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 74 - let req = Rockbox_V1alpha1_NextRequest() 75 - let _ = try await playback.next(req) 73 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 74 + let req = Rockbox_V1alpha1_NextRequest() 75 + let _ = try await playback.next(req) 76 76 } 77 77 } 78 78 79 79 func currentTrackStream() -> AsyncThrowingStream<Rockbox_V1alpha1_CurrentTrackResponse, Error> { 80 - AsyncThrowingStream { continuation in 81 - Task { 82 - do { 83 - try await withGRPCClient( 84 - transport: .http2NIOPosix( 85 - target: .dns(host: "127.0.0.1", port: 6061), 86 - transportSecurity: .plaintext 87 - ) 88 - ) { grpcClient in 89 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 90 - let req = Rockbox_V1alpha1_StreamCurrentTrackRequest() 91 - 92 - try await playback.streamCurrentTrack(req) { response in 93 - for try await message in response.messages { 94 - continuation.yield(message) 95 - } 96 - continuation.finish() 97 - } 98 - } 99 - } catch { 100 - continuation.finish(throwing: error) 80 + AsyncThrowingStream { continuation in 81 + Task { 82 + do { 83 + try await withGRPCClient( 84 + transport: .http2NIOPosix( 85 + target: .dns(host: "127.0.0.1", port: 6061), 86 + transportSecurity: .plaintext 87 + ) 88 + ) { grpcClient in 89 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 90 + let req = Rockbox_V1alpha1_StreamCurrentTrackRequest() 91 + 92 + try await playback.streamCurrentTrack(req) { response in 93 + for try await message in response.messages { 94 + continuation.yield(message) 101 95 } 96 + continuation.finish() 97 + } 102 98 } 99 + } catch { 100 + continuation.finish(throwing: error) 101 + } 103 102 } 103 + } 104 104 } 105 105 106 106 func playbackStatusStream() -> AsyncThrowingStream<Rockbox_V1alpha1_StatusResponse, Error> { 107 - AsyncThrowingStream { continuation in 108 - Task { 109 - do { 110 - try await withGRPCClient( 111 - transport: .http2NIOPosix( 112 - target: .dns(host: "127.0.0.1", port: 6061), 113 - transportSecurity: .plaintext 114 - ) 115 - ) { grpcClient in 116 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 117 - let req = Rockbox_V1alpha1_StreamStatusRequest() 118 - 119 - try await playback.streamStatus(req) { response in 120 - for try await message in response.messages { 121 - continuation.yield(message) 122 - } 123 - continuation.finish() 124 - } 125 - } 126 - } catch { 127 - continuation.finish(throwing: error) 107 + AsyncThrowingStream { continuation in 108 + Task { 109 + do { 110 + try await withGRPCClient( 111 + transport: .http2NIOPosix( 112 + target: .dns(host: "127.0.0.1", port: 6061), 113 + transportSecurity: .plaintext 114 + ) 115 + ) { grpcClient in 116 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 117 + let req = Rockbox_V1alpha1_StreamStatusRequest() 118 + 119 + try await playback.streamStatus(req) { response in 120 + for try await message in response.messages { 121 + continuation.yield(message) 128 122 } 123 + continuation.finish() 124 + } 129 125 } 126 + } catch { 127 + continuation.finish(throwing: error) 128 + } 130 129 } 130 + } 131 131 } 132 132 133 - func playAlbum(albumID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 133 + func playAlbum( 134 + albumID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 135 + port: Int = 6061 136 + ) async throws { 134 137 try await withGRPCClient( 135 138 transport: .http2NIOPosix( 136 139 target: .dns(host: host, port: port), 137 140 transportSecurity: .plaintext 138 141 ) 139 142 ) { grpcClient in 140 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 141 - var req = Rockbox_V1alpha1_PlayAlbumRequest() 142 - req.albumID = albumID 143 - req.shuffle = shuffle 144 - req.position = position 145 - let _ = try await playback.playAlbum(req) 143 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 144 + var req = Rockbox_V1alpha1_PlayAlbumRequest() 145 + req.albumID = albumID 146 + req.shuffle = shuffle 147 + req.position = position 148 + let _ = try await playback.playAlbum(req) 146 149 } 147 150 } 148 151 149 - func playDirectory(path: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 152 + func playDirectory( 153 + path: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 154 + port: Int = 6061 155 + ) async throws { 150 156 try await withGRPCClient( 151 157 transport: .http2NIOPosix( 152 158 target: .dns(host: host, port: port), 153 159 transportSecurity: .plaintext 154 160 ) 155 161 ) { grpcClient in 156 - if path.isEmpty { 157 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 158 - var req = Rockbox_V1alpha1_PlayMusicDirectoryRequest() 159 - req.position = position 160 - req.shuffle = shuffle 161 - let _ = try await playback.playMusicDirectory(req) 162 - return 163 - } 162 + if path.isEmpty { 164 163 let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 165 - var req = Rockbox_V1alpha1_PlayDirectoryRequest() 166 - req.path = path 164 + var req = Rockbox_V1alpha1_PlayMusicDirectoryRequest() 165 + req.position = position 167 166 req.shuffle = shuffle 168 - req.position = position 169 - let _ = try await playback.playDirectory(req) 167 + let _ = try await playback.playMusicDirectory(req) 168 + return 169 + } 170 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 171 + var req = Rockbox_V1alpha1_PlayDirectoryRequest() 172 + req.path = path 173 + req.shuffle = shuffle 174 + req.position = position 175 + let _ = try await playback.playDirectory(req) 170 176 } 171 177 } 172 178 173 - 174 - 175 - func playTrack(path: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 179 + func playTrack(path: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 176 180 try await withGRPCClient( 177 181 transport: .http2NIOPosix( 178 182 target: .dns(host: host, port: port), 179 183 transportSecurity: .plaintext 180 184 ) 181 185 ) { grpcClient in 182 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 183 - var req = Rockbox_V1alpha1_PlayTrackRequest() 184 - req.path = path 185 - let _ = try await playback.playTrack(req) 186 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 187 + var req = Rockbox_V1alpha1_PlayTrackRequest() 188 + req.path = path 189 + let _ = try await playback.playTrack(req) 186 190 } 187 191 } 188 192 189 - func playAllTracks(shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 193 + func playAllTracks( 194 + shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061 195 + ) async throws { 190 196 try await withGRPCClient( 191 197 transport: .http2NIOPosix( 192 198 target: .dns(host: host, port: port), 193 199 transportSecurity: .plaintext 194 200 ) 195 201 ) { grpcClient in 196 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 197 - var req = Rockbox_V1alpha1_PlayAllTracksRequest() 198 - req.shuffle = shuffle 199 - req.position = position 200 - let _ = try await playback.playAllTracks(req) 202 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 203 + var req = Rockbox_V1alpha1_PlayAllTracksRequest() 204 + req.shuffle = shuffle 205 + req.position = position 206 + let _ = try await playback.playAllTracks(req) 201 207 } 202 208 } 203 209 204 - func playLikedTracks(shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 210 + func playLikedTracks( 211 + shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061 212 + ) async throws { 205 213 try await withGRPCClient( 206 214 transport: .http2NIOPosix( 207 215 target: .dns(host: host, port: port), 208 216 transportSecurity: .plaintext 209 217 ) 210 218 ) { grpcClient in 211 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 212 - var req = Rockbox_V1alpha1_PlayLikedTracksRequest() 213 - req.shuffle = shuffle 214 - req.position = position 215 - let _ = try await playback.playLikedTracks(req) 219 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 220 + var req = Rockbox_V1alpha1_PlayLikedTracksRequest() 221 + req.shuffle = shuffle 222 + req.position = position 223 + let _ = try await playback.playLikedTracks(req) 216 224 } 217 225 } 218 226 219 - func playArtistTracks(artistID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 227 + func playArtistTracks( 228 + artistID: String, shuffle: Bool = false, position: Int32 = 0, host: String = "127.0.0.1", 229 + port: Int = 6061 230 + ) async throws { 220 231 try await withGRPCClient( 221 232 transport: .http2NIOPosix( 222 233 target: .dns(host: host, port: port), 223 234 transportSecurity: .plaintext 224 235 ) 225 236 ) { grpcClient in 226 - let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 227 - var req = Rockbox_V1alpha1_PlayArtistTracksRequest() 228 - req.shuffle = shuffle 229 - req.artistID = artistID 230 - req.position = position 231 - let _ = try await playback.playArtistTracks(req) 237 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 238 + var req = Rockbox_V1alpha1_PlayArtistTracksRequest() 239 + req.shuffle = shuffle 240 + req.artistID = artistID 241 + req.position = position 242 + let _ = try await playback.playArtistTracks(req) 232 243 } 233 244 }
+116 -31
macos/Rockbox/Services/PlaylistService.swift
··· 8 8 import GRPCCore 9 9 import GRPCNIOTransportHTTP2 10 10 11 - func fetchCurrentPlaylist(host: String = "127.0.0.1", port: Int = 6061) async throws -> Rockbox_V1alpha1_GetCurrentResponse { 11 + func fetchCurrentPlaylist(host: String = "127.0.0.1", port: Int = 6061) async throws 12 + -> Rockbox_V1alpha1_GetCurrentResponse 13 + { 12 14 try await withGRPCClient( 13 15 transport: .http2NIOPosix( 14 16 target: .dns(host: host, port: port), ··· 25 27 } 26 28 } 27 29 28 - func resumeTrack(host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 30 + func resumeTrack(host: String = "127.0.0.1", port: Int = 6061) async throws { 29 31 try await withGRPCClient( 30 32 transport: .http2NIOPosix( 31 33 target: .dns(host: host, port: port), 32 34 transportSecurity: .plaintext 33 35 ) 34 36 ) { grpcClient in 35 - let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 36 - let req = Rockbox_V1alpha1_ResumeTrackRequest() 37 - let _ = try await playlist.resumeTrack(req) 37 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 38 + let req = Rockbox_V1alpha1_ResumeTrackRequest() 39 + let _ = try await playlist.resumeTrack(req) 38 40 } 39 41 } 40 42 41 43 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) 44 + AsyncThrowingStream { continuation in 45 + Task { 46 + do { 47 + try await withGRPCClient( 48 + transport: .http2NIOPosix( 49 + target: .dns(host: "127.0.0.1", port: 6061), 50 + transportSecurity: .plaintext 51 + ) 52 + ) { grpcClient in 53 + let playback = Rockbox_V1alpha1_PlaybackService.Client(wrapping: grpcClient) 54 + let req = Rockbox_V1alpha1_StreamPlaylistRequest() 55 + 56 + try await playback.streamPlaylist(req) { response in 57 + for try await message in response.messages { 58 + continuation.yield(message) 63 59 } 60 + continuation.finish() 61 + } 64 62 } 63 + } catch { 64 + continuation.finish(throwing: error) 65 + } 65 66 } 67 + } 66 68 } 67 69 68 - func startPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 70 + func startPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws { 71 + try await withGRPCClient( 72 + transport: .http2NIOPosix( 73 + target: .dns(host: host, port: port), 74 + transportSecurity: .plaintext 75 + ) 76 + ) { grpcClient in 77 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 78 + var req = Rockbox_V1alpha1_StartRequest() 79 + req.startIndex = position 80 + let _ = try await playlist.start(req) 81 + } 82 + } 83 + 84 + func removeFromPlaylist(position: Int32, host: String = "127.0.0.1", port: Int = 6061) async throws 85 + { 86 + try await withGRPCClient( 87 + transport: .http2NIOPosix( 88 + target: .dns(host: host, port: port), 89 + transportSecurity: .plaintext 90 + ) 91 + ) { grpcClient in 92 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 93 + var req = Rockbox_V1alpha1_RemoveTracksRequest() 94 + req.positions = [position] 95 + let _ = try await playlist.removeTracks(req) 96 + } 97 + } 98 + 99 + func insertTracks( 100 + tracks: [String], 101 + position: Int32, 102 + shuffle: Bool = false, 103 + host: String = "127.0.0.1", 104 + port: Int = 6061 105 + ) async throws { 106 + try await withGRPCClient( 107 + transport: .http2NIOPosix( 108 + target: .dns(host: host, port: port), 109 + transportSecurity: .plaintext 110 + ) 111 + ) { grpcClient in 112 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 113 + var req = Rockbox_V1alpha1_InsertTracksRequest() 114 + req.tracks = tracks 115 + req.position = position 116 + req.shuffle = shuffle 117 + let _ = try await playlist.insertTracks(req) 118 + } 119 + } 120 + 121 + func insertAlbum( 122 + albumID: String, 123 + position: Int32, 124 + shuffle: Bool = false, 125 + host: String = "127.0.0.1", 126 + port: Int = 6061 127 + ) async throws { 69 128 try await withGRPCClient( 70 129 transport: .http2NIOPosix( 71 130 target: .dns(host: host, port: port), 72 131 transportSecurity: .plaintext 73 132 ) 74 133 ) { 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) 134 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 135 + var req = Rockbox_V1alpha1_InsertAlbumRequest() 136 + req.albumID = albumID 137 + req.position = position 138 + req.shuffle = shuffle 139 + let _ = try await playlist.insertAlbum(req) 140 + } 141 + } 142 + 143 + func insertDirectory( 144 + directory: String, 145 + position: Int32, 146 + recurse: Bool = false, 147 + shuffle: Bool = false, 148 + host: String = "127.0.0.1", 149 + port: Int = 6061 150 + ) async throws { 151 + try await withGRPCClient( 152 + transport: .http2NIOPosix( 153 + target: .dns(host: host, port: port), 154 + transportSecurity: .plaintext 155 + ) 156 + ) { grpcClient in 157 + let playlist = Rockbox_V1alpha1_PlaylistService.Client(wrapping: grpcClient) 158 + var req = Rockbox_V1alpha1_InsertDirectoryRequest() 159 + req.directory = directory 160 + req.position = position 161 + req.recurse = recurse 162 + req.shuffle = shuffle 163 + let _ = try await playlist.insertDirectory(req) 79 164 } 80 165 }
+4 -2
macos/Rockbox/Services/SearchService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func search(host: String = "127.0.0.1", port: Int = 6061, term: String) async throws -> Rockbox_V1alpha1_SearchResponse { 12 + func search(host: String = "127.0.0.1", port: Int = 6061, term: String) async throws 13 + -> Rockbox_V1alpha1_SearchResponse 14 + { 13 15 try await withGRPCClient( 14 16 transport: .http2NIOPosix( 15 17 target: .dns(host: host, port: port), ··· 17 19 ) 18 20 ) { grpcClient in 19 21 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 20 - 22 + 21 23 var req = Rockbox_V1alpha1_SearchRequest() 22 24 req.term = term 23 25
+3 -1
macos/Rockbox/Services/SystemService.swift
··· 9 9 import GRPCCore 10 10 import GRPCNIOTransportHTTP2 11 11 12 - func fetchGlobalStatus(host: String = "127.0.0.1", port: Int = 6061) async throws -> Rockbox_V1alpha1_GetGlobalStatusResponse { 12 + func fetchGlobalStatus(host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> Rockbox_V1alpha1_GetGlobalStatusResponse 14 + { 13 15 try await withGRPCClient( 14 16 transport: .http2NIOPosix( 15 17 target: .dns(host: host, port: port),
+8 -8
macos/Rockbox/Services/TrackService.swift
··· 5 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 6 // 7 7 8 - 9 8 import Foundation 10 9 import GRPCCore 11 10 import GRPCNIOTransportHTTP2 12 11 13 - func fetchTracks(host: String = "127.0.0.1", port: Int = 6061) async throws -> [Rockbox_V1alpha1_Track] { 12 + func fetchTracks(host: String = "127.0.0.1", port: Int = 6061) async throws 13 + -> [Rockbox_V1alpha1_Track] 14 + { 14 15 try await withGRPCClient( 15 16 transport: .http2NIOPosix( 16 17 target: .dns(host: host, port: port), ··· 27 28 } 28 29 } 29 30 30 - func fetchLikedTracks(host: String = "127.0.0.1", port: Int = 6061) async throws -> [Rockbox_V1alpha1_Track] { 31 + func fetchLikedTracks(host: String = "127.0.0.1", port: Int = 6061) async throws 32 + -> [Rockbox_V1alpha1_Track] 33 + { 31 34 try await withGRPCClient( 32 35 transport: .http2NIOPosix( 33 36 target: .dns(host: host, port: port), ··· 44 47 } 45 48 } 46 49 47 - func likeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 50 + func likeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 48 51 try await withGRPCClient( 49 52 transport: .http2NIOPosix( 50 53 target: .dns(host: host, port: port), ··· 59 62 } 60 63 } 61 64 62 - 63 - func unlikeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws -> Void { 65 + func unlikeTrack(id: String, host: String = "127.0.0.1", port: Int = 6061) async throws { 64 66 try await withGRPCClient( 65 67 transport: .http2NIOPosix( 66 68 target: .dns(host: host, port: port), ··· 74 76 let _ = try await library.unlikeTrack(req) 75 77 } 76 78 } 77 - 78 -
+16 -1
macos/Rockbox/State/PlayerState.swift
··· 12 12 @Published var isPlaying = false 13 13 @Published var currentTime: TimeInterval = 0 14 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)) 15 + @Published var currentTrack = Song(cuid: "", path: "", title: "Not Playing", artist: "", album: "", albumArt: nil, duration: TimeInterval(0), trackNumber: 0, discNumber: 0, color: .gray.opacity(0.3)) 16 16 @Published var queue: [Song] = [] 17 17 @Published var currentIndex: Int = 0 18 18 @Published var playlistLength: Int = 0 ··· 50 50 for try await response in currentTrackStream() { 51 51 self.currentTrack = Song( 52 52 cuid: response.id, 53 + path: response.path, 53 54 title: response.title, 54 55 artist: response.artist, 55 56 album: response.album, ··· 97 98 self.queue = data.tracks.map { track in 98 99 Song( 99 100 cuid: track.id, 101 + path: track.path, 100 102 title: track.title, 101 103 artist: track.artist, 102 104 album: track.album, ··· 137 139 self.queue = data.tracks.map { track in 138 140 Song( 139 141 cuid: track.id, 142 + path: track.path, 140 143 title: track.title, 141 144 artist: track.artist, 142 145 album: track.album, ··· 168 171 self.queue = data.tracks.map { track in 169 172 Song( 170 173 cuid: track.id, 174 + path: track.path, 171 175 title: track.title, 172 176 artist: track.artist, 173 177 album: track.album, ··· 242 246 try await startPlaylist(position: Int32(index)) 243 247 self.currentIndex = index 244 248 self.currentTrack = queue[index] 249 + } catch { 250 + self.error = error 251 + } 252 + } 253 + } 254 + 255 + func removeFromQueue(at index: Int) { 256 + Task { 257 + do { 258 + try await removeFromPlaylist(position: Int32(index)) 259 + await fetchQueue() 245 260 } catch { 246 261 self.error = error 247 262 }
+1
macos/Rockbox/Views/AlbumDetail/AlbumDetailView.swift
··· 92 92 for track in data.tracks { 93 93 tracks.append(Song( 94 94 cuid: track.id, 95 + path: track.path, 95 96 title: track.title, 96 97 artist: track.artist, 97 98 album: track.album,
+88
macos/Rockbox/Views/AlbumDetail/AlbumTrackView.swift
··· 13 13 let isEven: Bool 14 14 let albumID: String 15 15 @State private var errorText: String? 16 + @State private var isHoveringMenu: Bool = false 16 17 @ObservedObject var library: MusicLibrary 18 + @EnvironmentObject var player: PlayerState 17 19 18 20 @State private var isHovering = false 19 21 ··· 28 30 Task { 29 31 do { 30 32 try await playAlbum(albumID: albumID, position: Int32(index) - 1) 33 + await player.fetchQueue() 31 34 } catch { 32 35 errorText = String(describing: error) 33 36 } ··· 64 67 } 65 68 .buttonStyle(.plain) 66 69 .frame(width: 40, alignment: .center) 70 + 71 + 72 + /* 73 + Play Next 74 + Add to Playlist 75 + Play Last 76 + Add Shuffled 77 + */ 78 + // Context menu button 79 + Menu { 80 + Button(action: { 81 + Task { 82 + do { 83 + try await insertTracks(tracks: [track.path], position: Int32(PlaylistPosition.insertFirst)) 84 + await player.fetchQueue() 85 + } catch { 86 + errorText = String(describing: error) 87 + } 88 + } 89 + }) { 90 + Label("Play Next", systemImage: "text.insert") 91 + } 92 + 93 + Button(action: { 94 + Task { 95 + do { 96 + // Add to Playlist 97 + } catch { 98 + errorText = String(describing: error) 99 + } 100 + } 101 + }) { 102 + Label("Add to Playlist", systemImage: "text.append") 103 + } 104 + 105 + Button(action: { 106 + Task { 107 + do { 108 + try await insertTracks(tracks: [track.path], position: Int32(PlaylistPosition.insertLast)) 109 + await player.fetchQueue() 110 + } catch { 111 + errorText = String(describing: error) 112 + } 113 + } 114 + }) { 115 + Label("Play Last", systemImage: "text.append") 116 + } 117 + 118 + Divider() 119 + 120 + Button(action: { 121 + library.toggleLike(track) 122 + }) { 123 + Label(library.isLiked(track) ? "Remove from Liked" : "Add to Liked", 124 + systemImage: library.isLiked(track) ? "heart.slash" : "heart") 125 + } 126 + 127 + Divider() 128 + 129 + Button(action: { 130 + // Go to album action 131 + }) { 132 + Label("Go to Album", systemImage: "square.stack") 133 + } 134 + 135 + Button(action: { 136 + // Go to artist action 137 + }) { 138 + Label("Go to Artist", systemImage: "music.mic") 139 + 140 + } 141 + } label: { 142 + Image(systemName: "ellipsis") 143 + .font(.system(size: 14)) 144 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 145 + .frame(width: 32, height: 32) 146 + .contentShape(Rectangle()) 147 + } 148 + .menuStyle(.borderlessButton) 149 + .menuIndicator(.hidden) 150 + .frame(width: 40, alignment: .center) 151 + .opacity(isHovering ? 1 : 0) 152 + .onHover { hovering in 153 + isHoveringMenu = hovering 154 + } 67 155 } 68 156 .font(.system(size: 12)) 69 157 .padding(.horizontal, 24)
+245 -55
macos/Rockbox/Views/Albums/AlbumCardView.swift
··· 4 4 // 5 5 // Created by Tsiry Sandratraina on 14/12/2025. 6 6 // 7 - 8 7 import SwiftUI 9 8 10 9 struct AlbumCardView: View { 11 10 let album: Album 11 + var playlists: [Playlist] = [] 12 12 @State private var isHovering = false 13 - @State private var isHoveringPlayButton = false // Track play button hover 13 + @State private var isHoveringPlayButton = false 14 + @State private var isHoveringMenu = false 15 + @State private var showMenu = false 14 16 @State private var errorText: String? 15 17 var onSelect: () -> Void 16 18 17 19 var body: some View { 18 20 VStack(alignment: .leading, spacing: 8) { 19 21 // Album artwork 20 - ZStack { 21 - RoundedRectangle(cornerRadius: 5) 22 - .fill(album.color.gradient) 23 - .aspectRatio(1, contentMode: .fit) 24 - .overlay { 25 - AsyncImage(url: URL(string: album.cover)) { phase in 26 - switch phase { 27 - case .empty: 28 - Image(systemName: "music.note") 29 - .font(.system(size: 40)) 30 - .foregroundStyle(.white.opacity(0.6)) 31 - case .success(let image): 32 - image 33 - .resizable() 34 - .aspectRatio(contentMode: .fill) 35 - case .failure: 36 - Image(systemName: "music.note") 37 - .font(.system(size: 40)) 38 - .foregroundStyle(.white.opacity(0.6)) 39 - @unknown default: 40 - EmptyView() 41 - } 22 + RoundedRectangle(cornerRadius: 5) 23 + .fill(album.color.gradient) 24 + .aspectRatio(1, contentMode: .fit) 25 + .overlay { 26 + CachedAsyncImage(url: URL(string: album.cover)) { phase in 27 + switch phase { 28 + case .success(let image): 29 + image 30 + .resizable() 31 + .aspectRatio(contentMode: .fill) 32 + default: 33 + Image(systemName: "music.note") 34 + .font(.system(size: 40)) 35 + .foregroundStyle(.white.opacity(0.6)) 42 36 } 43 37 } 44 - .clipShape(RoundedRectangle(cornerRadius: 5)) 45 - .onTapGesture { 46 - onSelect() // Tap on artwork selects album 47 - } 48 - 49 - if isHovering { 50 - Button(action: { 51 - Task { 52 - do { 53 - try await playAlbum(albumID: album.cuid) 54 - } catch { 55 - errorText = String(describing: error) 38 + } 39 + .overlay(alignment: .bottom) { 40 + if isHovering || showMenu { 41 + HStack(spacing: 0) { 42 + // Play button - left half 43 + Button(action: { 44 + Task { 45 + do { 46 + try await playAlbum(albumID: album.cuid) 47 + } catch { 48 + errorText = String(describing: error) 49 + } 50 + } 51 + }) { 52 + Circle() 53 + .fill(isHoveringPlayButton ? Color(hex: "fe09a3") : .white.opacity(0.3)) 54 + .frame(width: 36, height: 36) 55 + .overlay { 56 + Image(systemName: "play.fill") 57 + .font(.system(size: 14)) 58 + .foregroundStyle(.white) 59 + } 56 60 } 57 - } 58 - }) { 59 - ZStack { 60 - Circle() 61 - .fill(isHoveringPlayButton ? Color(hex: "fe09a3") : .white.opacity(0.3)) 62 - .frame(width: 44, height: 44) 61 + .buttonStyle(.borderless) 62 + .onHover { isHoveringPlayButton = $0 } 63 + .frame(maxWidth: .infinity, alignment: .center) 63 64 64 - Image(systemName: "play.fill") 65 - .font(.system(size: 18)) 66 - .foregroundStyle(.white) 65 + // Context menu - right half 66 + ZStack { 67 + Circle() 68 + .fill(isHoveringMenu || showMenu ? Color(hex: "fe09a3") : .white.opacity(0.3)) 69 + .frame(width: 36, height: 36) 70 + 71 + Image(systemName: "ellipsis") 72 + .font(.system(size: 14, weight: .medium)) 73 + .foregroundStyle(.white) 74 + .allowsHitTesting(false) 75 + 76 + Button(action: { showMenu.toggle() }) { 77 + Circle() 78 + .fill(Color.clear) 79 + .frame(width: 36, height: 36) 80 + } 81 + .buttonStyle(.borderless) 82 + .onHover { isHoveringMenu = $0 } 83 + .popover(isPresented: $showMenu, arrowEdge: .bottom) { 84 + ZStack { 85 + Color.white.ignoresSafeArea() 86 + 87 + VStack(alignment: .leading, spacing: 0) { 88 + MenuItemButton(title: "Play", icon: "play.fill") { 89 + showMenu = false 90 + Task { 91 + do { 92 + try await playAlbum(albumID: album.cuid) 93 + } catch { 94 + errorText = String(describing: error) 95 + } 96 + } 97 + } 98 + 99 + MenuItemButton(title: "Play Shuffled", icon: "shuffle") { 100 + showMenu = false 101 + Task { 102 + do { 103 + try await playAlbum(albumID: album.cuid, shuffle: true) 104 + } catch { 105 + errorText = String(describing: error) 106 + } 107 + } 108 + } 109 + 110 + Divider().padding(.vertical, 4) 111 + 112 + MenuItemButton(title: "Play Next", icon: "text.insert") { 113 + showMenu = false 114 + Task { 115 + do { 116 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertFirst)) 117 + } catch { 118 + errorText = String(describing: error) 119 + } 120 + } 121 + } 122 + 123 + MenuItemButton(title: "Play Last", icon: "text.append") { 124 + showMenu = false 125 + Task { 126 + do { 127 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertLast)) 128 + } catch { 129 + errorText = String(describing: error) 130 + } 131 + } 132 + 133 + } 134 + 135 + Divider().padding(.vertical, 4) 136 + 137 + MenuItemButton( 138 + title: "Add to Playlist", 139 + icon: "music.note.list", 140 + hasSubmenu: true, 141 + submenuItems: playlists, 142 + onSubmenuSelect: { playlist in 143 + showMenu = false 144 + // Add to selected playlist 145 + }, 146 + onCreateNew: { 147 + showMenu = false 148 + // Create new playlist 149 + }, 150 + action: {} 151 + ) 152 + 153 + Divider().padding(.vertical, 4) 154 + 155 + MenuItemButton(title: "Add Shuffled", icon: "shuffle") { 156 + showMenu = false 157 + Task { 158 + do { 159 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertShuffled)) 160 + } catch { 161 + errorText = String(describing: error) 162 + } 163 + } 164 + 165 + } 166 + 167 + MenuItemButton(title: "Play Last Shuffled", icon: "shuffle") { 168 + showMenu = false 169 + Task { 170 + do { 171 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertLastShuffled)) 172 + } catch { 173 + errorText = String(describing: error) 174 + } 175 + } 176 + 177 + } 178 + } 179 + .padding(8) 180 + .frame(width: 200) 181 + } 182 + } 183 + } 184 + .frame(maxWidth: .infinity, alignment: .center) 67 185 } 186 + .padding(.vertical, 12) 187 + .transition(.opacity.combined(with: .move(edge: .bottom))) 68 188 } 69 - .buttonStyle(.borderless) 70 - .onHover { hovering in 71 - withAnimation(.easeInOut(duration: 0.15)) { 72 - isHoveringPlayButton = hovering 189 + } 190 + .clipShape(RoundedRectangle(cornerRadius: 5)) 191 + .onTapGesture { 192 + onSelect() 193 + } 194 + .onHover { hovering in 195 + withAnimation(.easeInOut(duration: 0.2)) { 196 + if !showMenu { 197 + isHovering = hovering 73 198 } 74 199 } 75 200 } 76 - } 77 - .onHover { hovering in 78 - withAnimation(.easeInOut(duration: 0.2)) { 79 - isHovering = hovering 201 + .onChange(of: showMenu) { oldValue, newValue in 202 + if !newValue { 203 + withAnimation(.easeInOut(duration: 0.2)) { 204 + isHovering = false 205 + } 206 + } 80 207 } 81 - } 82 208 83 - // Album info - tapping here also selects 209 + // Album info 84 210 VStack(alignment: .leading, spacing: 2) { 85 211 Text(album.title) 86 212 .font(.system(size: 12, weight: .medium)) ··· 102 228 } 103 229 } 104 230 } 231 + 232 + struct MenuItemButton: View { 233 + let title: String 234 + let icon: String 235 + var hasSubmenu: Bool = false 236 + var submenuItems: [Playlist] = [] 237 + var onSubmenuSelect: ((Playlist) -> Void)? = nil 238 + var onCreateNew: (() -> Void)? = nil 239 + let action: () -> Void 240 + 241 + @State private var isHovering = false 242 + 243 + var body: some View { 244 + if hasSubmenu { 245 + Menu { 246 + Button(action: { onCreateNew?() }) { 247 + Label("New Playlist...", systemImage: "plus") 248 + } 249 + 250 + if !submenuItems.isEmpty { 251 + Divider() 252 + 253 + ForEach(submenuItems) { playlist in 254 + Button(action: { onSubmenuSelect?(playlist) }) { 255 + Label(playlist.name, systemImage: "music.note.list") 256 + } 257 + } 258 + } 259 + } label: { 260 + HStack(spacing: 10) { 261 + Image(systemName: icon) 262 + .frame(width: 20) 263 + Text(title) 264 + Spacer() 265 + Image(systemName: "chevron.right") 266 + .font(.system(size: 10)) 267 + .foregroundStyle(.secondary) 268 + } 269 + .padding(.horizontal, 8) 270 + .padding(.vertical, 6) 271 + .background(isHovering ? Color.secondary.opacity(0.1) : Color.clear) 272 + .cornerRadius(4) 273 + } 274 + .menuStyle(.borderlessButton) 275 + .menuIndicator(.hidden) 276 + .onHover { isHovering = $0 } 277 + } else { 278 + Button(action: action) { 279 + HStack(spacing: 10) { 280 + Image(systemName: icon) 281 + .frame(width: 20) 282 + Text(title) 283 + Spacer() 284 + } 285 + .padding(.horizontal, 8) 286 + .padding(.vertical, 6) 287 + .background(isHovering ? Color.secondary.opacity(0.1) : Color.clear) 288 + .cornerRadius(4) 289 + } 290 + .buttonStyle(.plain) 291 + .onHover { isHovering = $0 } 292 + } 293 + } 294 + }
+1 -1
macos/Rockbox/Views/Albums/AlbumsGridView.swift
··· 20 20 ScrollView { 21 21 LazyVGrid(columns: columns, spacing: 24) { 22 22 ForEach(albums) { album in 23 - AlbumCardView(album: album) { 23 + AlbumCardView(album: album, playlists: []) { 24 24 selectedAlbum = album 25 25 } 26 26 }
+191 -56
macos/Rockbox/Views/ArtistDetail/ArtistAlbumCardView.swift
··· 9 9 10 10 struct ArtistAlbumCardView: View { 11 11 let album: Album 12 + var playlists: [Playlist] = [] 12 13 var onSelect: () -> Void 13 14 14 15 @State private var isHovering = false 15 16 @State private var isHoveringPlayButton = false 17 + @State private var isHoveringMenu = false 18 + @State private var showMenu = false 16 19 @State private var errorText: String? 17 20 21 + @EnvironmentObject var player: PlayerState 22 + 23 + 18 24 var body: some View { 19 25 VStack(alignment: .leading, spacing: 8) { 20 26 // Album artwork 21 - ZStack { 22 - RoundedRectangle(cornerRadius: 6) 23 - .fill(album.color.gradient) 24 - .aspectRatio(1, contentMode: .fit) 25 - .overlay { 26 - AsyncImage(url: URL(string: album.cover)) { phase in 27 - switch phase { 28 - case .empty: 29 - Image(systemName: "music.note") 30 - .font(.system(size: 40)) 31 - .foregroundStyle(.white.opacity(0.6)) 32 - case .success(let image): 33 - image 34 - .resizable() 35 - .aspectRatio(contentMode: .fill) 36 - case .failure: 37 - Image(systemName: "music.note") 38 - .font(.system(size: 40)) 39 - .foregroundStyle(.white.opacity(0.6)) 40 - @unknown default: 41 - EmptyView() 42 - } 27 + RoundedRectangle(cornerRadius: 5) 28 + .fill(album.color.gradient) 29 + .aspectRatio(1, contentMode: .fit) 30 + .overlay { 31 + CachedAsyncImage(url: URL(string: album.cover)) { phase in 32 + switch phase { 33 + case .success(let image): 34 + image 35 + .resizable() 36 + .aspectRatio(contentMode: .fill) 37 + default: 38 + Image(systemName: "music.note") 39 + .font(.system(size: 40)) 40 + .foregroundStyle(.white.opacity(0.6)) 43 41 } 44 42 } 45 - .clipShape(RoundedRectangle(cornerRadius: 5)) 46 - .onTapGesture { 47 - onSelect() 48 - } 49 - 50 - 51 - 52 - // Play button on hover 53 - if isHovering { 54 - Button(action: { 55 - Task { 56 - do { 57 - try await playAlbum(albumID: album.cuid) 58 - } catch { 59 - errorText = String(describing: error) 43 + } 44 + .overlay(alignment: .bottom) { 45 + if isHovering || showMenu { 46 + HStack(spacing: 0) { 47 + // Play button - left half 48 + Button(action: { 49 + Task { 50 + do { 51 + try await playAlbum(albumID: album.cuid) 52 + await player.fetchQueue() 53 + } catch { 54 + errorText = String(describing: error) 55 + } 56 + } 57 + }) { 58 + Circle() 59 + .fill(isHoveringPlayButton ? Color(hex: "fe09a3") : .white.opacity(0.3)) 60 + .frame(width: 32, height: 32) 61 + .overlay { 62 + Image(systemName: "play.fill") 63 + .font(.system(size: 12)) 64 + .foregroundStyle(.white) 65 + } 60 66 } 61 - } 62 - }) { 63 - ZStack { 64 - Circle() 65 - .fill(isHoveringPlayButton ? Color(hex: "fe09a3") : .white.opacity(0.3)) 66 - .frame(width: 36, height: 36) 67 + .buttonStyle(.borderless) 68 + .onHover { isHoveringPlayButton = $0 } 69 + .frame(maxWidth: .infinity, alignment: .center) 67 70 68 - Image(systemName: "play.fill") 69 - .font(.system(size: 14)) 70 - .foregroundStyle(.white) 71 + // Context menu - right half 72 + ZStack { 73 + Circle() 74 + .fill(isHoveringMenu || showMenu ? Color(hex: "fe09a3") : .white.opacity(0.3)) 75 + .frame(width: 32, height: 32) 76 + 77 + Image(systemName: "ellipsis") 78 + .font(.system(size: 12, weight: .medium)) 79 + .foregroundStyle(.white) 80 + .allowsHitTesting(false) 81 + 82 + Button(action: { showMenu.toggle() }) { 83 + Circle() 84 + .fill(Color.clear) 85 + .frame(width: 32, height: 32) 86 + } 87 + .buttonStyle(.borderless) 88 + .onHover { isHoveringMenu = $0 } 89 + .popover(isPresented: $showMenu, arrowEdge: .bottom) { 90 + ZStack { 91 + Color.white.ignoresSafeArea() 92 + 93 + VStack(alignment: .leading, spacing: 0) { 94 + MenuItemButton(title: "Play", icon: "play.fill") { 95 + showMenu = false 96 + Task { 97 + do { 98 + try await playAlbum(albumID: album.cuid) 99 + await player.fetchQueue() 100 + } catch { 101 + errorText = String(describing: error) 102 + } 103 + } 104 + } 105 + 106 + MenuItemButton(title: "Play Shuffled", icon: "shuffle") { 107 + showMenu = false 108 + Task { 109 + do { 110 + try await playAlbum(albumID: album.cuid, shuffle: true) 111 + await player.fetchQueue() 112 + } catch { 113 + errorText = String(describing: error) 114 + } 115 + } 116 + } 117 + 118 + Divider().padding(.vertical, 4) 119 + 120 + MenuItemButton(title: "Play Next", icon: "text.insert") { 121 + showMenu = false 122 + Task { 123 + do { 124 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertFirst)) 125 + await player.fetchQueue() 126 + } catch { 127 + errorText = String(describing: error) 128 + } 129 + } 130 + } 131 + 132 + MenuItemButton(title: "Play Last", icon: "text.append") { 133 + showMenu = false 134 + Task { 135 + do { 136 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertLast)) 137 + await player.fetchQueue() 138 + } catch { 139 + errorText = String(describing: error) 140 + } 141 + } 142 + } 143 + 144 + Divider().padding(.vertical, 4) 145 + 146 + MenuItemButton( 147 + title: "Add to Playlist", 148 + icon: "music.note.list", 149 + hasSubmenu: true, 150 + submenuItems: playlists, 151 + onSubmenuSelect: { playlist in 152 + showMenu = false 153 + // Add to selected playlist 154 + }, 155 + onCreateNew: { 156 + showMenu = false 157 + // Create new playlist 158 + }, 159 + action: {} 160 + ) 161 + 162 + Divider().padding(.vertical, 4) 163 + 164 + MenuItemButton(title: "Add Shuffled", icon: "shuffle") { 165 + showMenu = false 166 + Task { 167 + do { 168 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertShuffled)) 169 + await player.fetchQueue() 170 + } catch { 171 + errorText = String(describing: error) 172 + } 173 + } 174 + } 175 + 176 + MenuItemButton(title: "Play Last Shuffled", icon: "shuffle") { 177 + showMenu = false 178 + Task { 179 + do { 180 + try await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertLastShuffled)) 181 + await player.fetchQueue() 182 + } catch { 183 + errorText = String(describing: error) 184 + } 185 + } 186 + } 187 + } 188 + .padding(8) 189 + .frame(width: 200) 190 + } 191 + } 192 + } 193 + .frame(maxWidth: .infinity, alignment: .center) 71 194 } 195 + .padding(.vertical, 10) 196 + .transition(.opacity.combined(with: .move(edge: .bottom))) 72 197 } 73 - .buttonStyle(.borderless) 74 - .onHover { hovering in 75 - withAnimation(.easeInOut(duration: 0.15)) { 76 - isHoveringPlayButton = hovering 198 + } 199 + .clipShape(RoundedRectangle(cornerRadius: 5)) 200 + .onTapGesture { 201 + onSelect() 202 + } 203 + .onHover { hovering in 204 + withAnimation(.easeInOut(duration: 0.2)) { 205 + if !showMenu { 206 + isHovering = hovering 77 207 } 78 208 } 79 209 } 80 - } 81 - .onHover { hovering in 82 - withAnimation(.easeInOut(duration: 0.2)) { 83 - isHovering = hovering 210 + .onChange(of: showMenu) { oldValue, newValue in 211 + if !newValue { 212 + withAnimation(.easeInOut(duration: 0.2)) { 213 + isHovering = false 214 + } 215 + } 84 216 } 85 - } 86 217 87 218 // Album info 88 219 VStack(alignment: .leading, spacing: 2) { ··· 97 228 .onTapGesture { 98 229 onSelect() 99 230 } 100 - 101 231 } 102 232 .contentShape(Rectangle()) 233 + .alert("gRPC Error", isPresented: .constant(errorText != nil)) { 234 + Button("OK") { errorText = nil } 235 + } message: { 236 + Text(errorText ?? "") 237 + } 103 238 } 104 239 } 105 240
+1 -1
macos/Rockbox/Views/ArtistDetail/ArtistDetailView.swift
··· 76 76 } 77 77 78 78 for track in data.tracks { 79 - tracks.append(Song(cuid: track.id, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000),trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3))) 79 + tracks.append(Song(cuid: track.id, path: track.path, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000),trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3))) 80 80 } 81 81 } catch { 82 82 errorText = String(describing: error)
+88 -2
macos/Rockbox/Views/ArtistDetail/ArtistTrackRowView.swift
··· 15 15 @ObservedObject var library: MusicLibrary 16 16 @State private var errorText: String? = nil 17 17 18 - @State private var isHovering = false 18 + @State private var isHovering: Bool = false 19 + @State private var isHoveringMenu: Bool = false 20 + @EnvironmentObject var player: PlayerState 19 21 20 22 var body: some View { 21 23 HStack(spacing: 12) { ··· 96 98 .foregroundStyle(library.isLiked(track) ? Color(hex: "fe09a3") : .secondary) 97 99 } 98 100 .buttonStyle(.plain) 99 - .opacity(isHovering || library.isLiked(track) ? 1 : 0) 100 101 .frame(width: 40, alignment: .center) 102 + 103 + /* 104 + Play Next 105 + Add to Playlist 106 + Play Last 107 + Add Shuffled 108 + */ 109 + // Context menu button 110 + Menu { 111 + Button(action: { 112 + Task { 113 + do { 114 + try await insertTracks(tracks: [track.path], position: Int32(PlaylistPosition.insertFirst)) 115 + await player.fetchQueue() 116 + } catch { 117 + errorText = String(describing: error) 118 + } 119 + } 120 + }) { 121 + Label("Play Next", systemImage: "text.insert") 122 + } 123 + 124 + Button(action: { 125 + Task { 126 + do { 127 + // Add to Playlist 128 + } catch { 129 + errorText = String(describing: error) 130 + } 131 + } 132 + }) { 133 + Label("Add to Playlist", systemImage: "text.append") 134 + } 135 + 136 + Button(action: { 137 + Task { 138 + do { 139 + try await insertTracks(tracks: [track.path], position: Int32(PlaylistPosition.insertLast)) 140 + await player.fetchQueue() 141 + } catch { 142 + errorText = String(describing: error) 143 + } 144 + } 145 + }) { 146 + Label("Play Last", systemImage: "text.append") 147 + } 148 + 149 + Divider() 150 + 151 + Button(action: { 152 + library.toggleLike(track) 153 + }) { 154 + Label(library.isLiked(track) ? "Remove from Liked" : "Add to Liked", 155 + systemImage: library.isLiked(track) ? "heart.slash" : "heart") 156 + } 157 + 158 + Divider() 159 + 160 + Button(action: { 161 + // Go to album action 162 + }) { 163 + Label("Go to Album", systemImage: "square.stack") 164 + } 165 + 166 + Button(action: { 167 + // Go to artist action 168 + }) { 169 + Label("Go to Artist", systemImage: "music.mic") 170 + 171 + } 172 + } label: { 173 + Image(systemName: "ellipsis") 174 + .font(.system(size: 14)) 175 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 176 + .frame(width: 32, height: 32) 177 + .contentShape(Rectangle()) 178 + } 179 + .menuStyle(.borderlessButton) 180 + .menuIndicator(.hidden) 181 + .frame(width: 40, alignment: .center) 182 + .opacity(isHovering ? 1 : 0) 183 + .onHover { hovering in 184 + isHoveringMenu = hovering 185 + } 186 + 101 187 } 102 188 .font(.system(size: 12)) 103 189 .padding(.horizontal, 24)
+46
macos/Rockbox/Views/Components/PlayerControlsView.swift
··· 12 12 @State private var isHoveringProgress = false 13 13 @State private var isHoveringTrackInfo = false 14 14 @State private var isHoveringQueue = false 15 + @State private var isHoveringMenu = false 16 + @State private var errorText: String? = nil 15 17 @ObservedObject var library: MusicLibrary 16 18 @Binding var showQueue: Bool // Add this binding 19 + 17 20 18 21 var body: some View { 19 22 HStack(spacing: 0) { ··· 97 100 } 98 101 .buttonStyle(.plain) 99 102 .opacity(isHoveringTrackInfo || library.isLiked(player.currentTrack) ? 1 : 0) 103 + 104 + // Context menu button 105 + Menu { 106 + Button(action: { 107 + Task { 108 + do { 109 + // try await addToQueueLast(songId: song.cuid) 110 + } catch { 111 + errorText = String(describing: error) 112 + } 113 + } 114 + }) { 115 + Label("Add to Playlist", systemImage: "text.append") 116 + } 117 + 118 + Divider() 119 + 120 + Button(action: { 121 + // Go to album action 122 + }) { 123 + Label("Go to Album", systemImage: "square.stack") 124 + } 125 + 126 + Button(action: { 127 + // Go to artist action 128 + }) { 129 + Label("Go to Artist", systemImage: "music.mic") 130 + 131 + } 132 + } label: { 133 + Image(systemName: "ellipsis") 134 + .font(.system(size: 14)) 135 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 136 + .frame(width: 32, height: 32) 137 + .contentShape(Rectangle()) 138 + } 139 + .menuStyle(.borderlessButton) 140 + .menuIndicator(.hidden) 141 + .frame(width: 40, alignment: .center) 142 + .opacity(isHoveringTrackInfo ? 1 : 0) 143 + .onHover { hovering in 144 + isHoveringMenu = hovering 145 + } 100 146 101 147 Spacer() 102 148 }
+119 -3
macos/Rockbox/Views/Components/QueueView.swift
··· 10 10 struct QueueView: View { 11 11 @EnvironmentObject var player: PlayerState 12 12 @State private var showPlayingNext: Bool = true 13 + @ObservedObject var library: MusicLibrary 13 14 14 15 var body: some View { 15 16 VStack(alignment: .leading, spacing: 0) { ··· 55 56 index: index, 56 57 onTap: { 57 58 player.playFromQueue(at: showPlayingNext ? player.currentIndex + 1 + index : index) 58 - } 59 + }, 60 + onRemove: { 61 + let queueIndex = showPlayingNext ? player.currentIndex + 1 + index : index 62 + player.removeFromQueue(at: queueIndex) 63 + }, 64 + library: library, 59 65 ) 60 66 } 61 67 } ··· 71 77 let song: Song 72 78 let index: Int 73 79 var onTap: () -> Void 74 - @State private var isHovering = false 80 + var onRemove: () -> Void 81 + @State private var errorText: String? = nil 82 + @State private var isHovering: Bool = false 83 + @State private var isHoveringMenu: Bool = false 84 + @State private var isHoveringRemove: Bool = false 85 + @ObservedObject var library: MusicLibrary 86 + @EnvironmentObject var player: PlayerState 87 + 75 88 76 89 var body: some View { 77 90 HStack(spacing: 12) { ··· 93 106 } 94 107 } 95 108 } 109 + .overlay(alignment: .topLeading) { 110 + if isHovering { 111 + Button(action: onRemove) { 112 + Image(systemName: "minus") 113 + .font(.system(size: 8, weight: .bold)) 114 + .foregroundStyle(.white) 115 + .frame(width: 14, height: 14) 116 + .background( 117 + Circle() 118 + .fill(isHoveringRemove ? Color.red : Color.red.opacity(0.85)) 119 + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) 120 + ) 121 + .contentShape(Circle()) 122 + } 123 + .buttonStyle(.plain) 124 + .padding(2) 125 + .onHover { isHoveringRemove = $0 } 126 + .transition(.scale.combined(with: .opacity)) 127 + } 128 + } 96 129 .clipShape(RoundedRectangle(cornerRadius: 4)) 97 130 98 131 VStack(alignment: .leading, spacing: 2) { ··· 105 138 .foregroundStyle(.secondary) 106 139 .lineLimit(1) 107 140 } 141 + .frame(maxWidth: .infinity, alignment: .leading) 108 142 109 - Spacer() 143 + /* 144 + Play Next 145 + Add to Playlist 146 + Play Last 147 + Add Shuffled 148 + */ 149 + // Context menu button 150 + Menu { 151 + Button(action: { 152 + Task { 153 + do { 154 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertFirst)) 155 + await player.fetchQueue() 156 + } catch { 157 + errorText = String(describing: error) 158 + } 159 + } 160 + }) { 161 + Label("Play Next", systemImage: "text.insert") 162 + } 163 + 164 + Button(action: { 165 + Task { 166 + do { 167 + // try await addToQueueLast(songId: song.cuid) 168 + } catch { 169 + errorText = String(describing: error) 170 + } 171 + } 172 + }) { 173 + Label("Add to Playlist", systemImage: "text.append") 174 + } 175 + 176 + Button(action: { 177 + Task { 178 + do { 179 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertLast)) 180 + await player.fetchQueue() 181 + } catch { 182 + errorText = String(describing: error) 183 + } 184 + } 185 + }) { 186 + Label("Play Last", systemImage: "text.append") 187 + } 188 + 189 + Divider() 190 + 191 + Button(action: { 192 + library.toggleLike(song) 193 + }) { 194 + Label(library.isLiked(song) ? "Remove from Liked" : "Add to Liked", 195 + systemImage: library.isLiked(song) ? "heart.slash" : "heart") 196 + } 197 + 198 + Divider() 199 + 200 + Button(action: { 201 + // Go to album action 202 + }) { 203 + Label("Go to Album", systemImage: "square.stack") 204 + } 205 + 206 + Button(action: { 207 + // Go to artist action 208 + }) { 209 + Label("Go to Artist", systemImage: "music.mic") 210 + 211 + } 212 + } label: { 213 + Image(systemName: "ellipsis") 214 + .font(.system(size: 14)) 215 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 216 + .frame(width: 32, height: 32) 217 + .contentShape(Rectangle()) 218 + } 219 + .menuStyle(.borderlessButton) 220 + .menuIndicator(.hidden) 221 + .frame(width: 20, alignment: .center) 222 + .opacity(isHovering ? 1 : 0) 223 + .onHover { hovering in 224 + isHoveringMenu = hovering 225 + } 110 226 } 111 227 .padding(.horizontal, 12) 112 228 .padding(.vertical, 8)
+1 -2
macos/Rockbox/Views/Files/FileHeaderRow.swift
··· 13 13 Text("Name") 14 14 .frame(maxWidth: .infinity, alignment: .leading) 15 15 16 - Text("Size") 17 - .frame(width: 100, alignment: .trailing) 16 + Color.clear.frame(width: 40) 18 17 } 19 18 .font(.system(size: 11, weight: .medium)) 20 19 .foregroundStyle(.secondary)
+82 -9
macos/Rockbox/Views/Files/FileRowView.swift
··· 14 14 let currentDirectory: String 15 15 16 16 @State private var isHovering = false 17 + @State private var isHoveringMenu = false 17 18 @State private var errorText: String? = nil 18 19 19 20 var body: some View { ··· 54 55 .lineLimit(1) 55 56 56 57 } 58 + 57 59 } 58 60 .frame(maxWidth: .infinity, alignment: .leading) 59 61 60 - // Size or item count 61 - if let size = file.size { 62 - Text(size) 63 - .font(.system(size: 11)) 64 - .foregroundStyle(.secondary) 65 - .frame(width: 100, alignment: .trailing) 66 - } else { 67 - Color.clear 68 - .frame(width: 100) 62 + /* 63 + Play Next 64 + Add to Playlist 65 + Play Last 66 + Add Shuffled 67 + */ 68 + // Context menu button 69 + Menu { 70 + Button(action: { 71 + Task { 72 + do { 73 + // try await addToQueue(songId: song.cuid) 74 + } catch { 75 + errorText = String(describing: error) 76 + } 77 + } 78 + }) { 79 + Label("Play Next", systemImage: "text.insert") 80 + } 81 + 82 + Button(action: { 83 + Task { 84 + do { 85 + // try await addToQueueLast(songId: song.cuid) 86 + } catch { 87 + errorText = String(describing: error) 88 + } 89 + } 90 + }) { 91 + Label("Add to Playlist", systemImage: "text.append") 92 + } 93 + 94 + Button(action: { 95 + Task { 96 + do { 97 + // try await addToQueueLast(songId: song.cuid) 98 + } catch { 99 + errorText = String(describing: error) 100 + } 101 + } 102 + }) { 103 + Label("Play Last", systemImage: "text.append") 104 + } 105 + 106 + Divider() 107 + 108 + Button(action: { 109 + // Add Shuffled 110 + }) { 111 + Label("Add Shuffled", systemImage: "square.stack") 112 + } 113 + 114 + if file.type == .directory { 115 + Button(action: { 116 + // Play Last Shuffled 117 + }) { 118 + Label("Play Last Shuffled", systemImage: "music.mic") 119 + 120 + } 121 + 122 + Button(action: { 123 + // Play Last Shuffled 124 + }) { 125 + Label("Play Shuffled", systemImage: "music.mic") 126 + 127 + } 128 + } 129 + } label: { 130 + Image(systemName: "ellipsis") 131 + .font(.system(size: 14)) 132 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 133 + .frame(width: 32, height: 32) 134 + .contentShape(Rectangle()) 135 + } 136 + .menuStyle(.borderlessButton) 137 + .menuIndicator(.hidden) 138 + .frame(width: 40, alignment: .center) 139 + .opacity(isHovering ? 1 : 0) 140 + .onHover { hovering in 141 + isHoveringMenu = hovering 69 142 } 70 143 } 71 144 .padding(.horizontal, 16)
+1
macos/Rockbox/Views/Likes/LikesListView.swift
··· 95 95 for track in data { 96 96 let song = Song( 97 97 cuid: track.id, 98 + path: track.path, 98 99 title: track.title, 99 100 artist: track.artist, 100 101 album: track.album,
+1 -1
macos/Rockbox/Views/Main/ContentView.swift
··· 20 20 DetailView(selection: selection, player: player, library: library, showQueue: $showQueue) 21 21 } 22 22 .inspector(isPresented: $showQueue) { 23 - QueueView() 23 + QueueView(library: library) 24 24 .inspectorColumnWidth(min: 280, ideal: 300, max: 350) 25 25 } 26 26 }
+1 -1
macos/Rockbox/Views/Main/DetailView.swift
··· 66 66 do { 67 67 let likes = try await fetchLikedTracks() 68 68 for track in likes { 69 - let song = Song(cuid: track.id, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3)) 69 + let song = Song(cuid: track.id, path: track.path, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3)) 70 70 library.likedSongIds.insert(song.cuid) 71 71 } 72 72 } catch {
+4
macos/Rockbox/Views/Songs/SongHeaderRow.swift
··· 32 32 Color.clear 33 33 .frame(width: 40) 34 34 } 35 + 36 + Color.clear 37 + .frame(width: 40) 38 + 35 39 } 36 40 .font(.system(size: 11, weight: .medium)) 37 41 .foregroundStyle(.secondary)
+94 -12
macos/Rockbox/Views/Songs/SongRowView.swift
··· 15 15 var isLikesScreen: Bool = false 16 16 @State private var errorText: String? 17 17 @ObservedObject var library: MusicLibrary 18 + @EnvironmentObject var player: PlayerState 18 19 19 20 @State private var isHovering = false 21 + @State private var isHoveringMenu = false 20 22 21 23 var body: some View { 22 24 HStack(spacing: 12) { ··· 52 54 .fill(song.color.gradient) 53 55 .frame(width: 36, height: 36) 54 56 .overlay { 55 - AsyncImage(url: song.albumArt) { phase in 57 + CachedAsyncImage(url: song.albumArt) { phase in 56 58 switch phase { 57 - case .empty: 58 - Image(systemName: "music.note") 59 - .font(.system(size: 14)) 60 - .foregroundStyle(.white.opacity(0.8)) 61 59 case .success(let image): 62 60 image 63 61 .resizable() 64 62 .aspectRatio(contentMode: .fill) 65 - case .failure: 63 + default: 66 64 Image(systemName: "music.note") 67 65 .font(.system(size: 14)) 68 66 .foregroundStyle(.white.opacity(0.8)) 69 - @unknown default: 70 - EmptyView() 71 67 } 72 68 } 73 69 } 74 70 .clipShape(RoundedRectangle(cornerRadius: 0)) 75 - 76 71 77 72 Text(song.title) 78 73 .lineLimit(1) ··· 105 100 }) { 106 101 Image(systemName: library.isLiked(song) ? "heart.fill" : "heart") 107 102 .font(.system(size: 14)) 108 - .foregroundStyle(library.isLiked(song) ? Color(hex:"#fe09a3") : .secondary) 103 + .foregroundStyle(library.isLiked(song) ? Color(hex: "#fe09a3") : .secondary) 109 104 } 110 105 .buttonStyle(.plain) 111 106 .frame(width: 40, alignment: .center) 112 107 } 108 + 109 + /* 110 + Play Next 111 + Add to Playlist 112 + Play Last 113 + Add Shuffled 114 + */ 115 + // Context menu button 116 + Menu { 117 + Button(action: { 118 + Task { 119 + do { 120 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertFirst)) 121 + await player.fetchQueue() 122 + } catch { 123 + errorText = String(describing: error) 124 + } 125 + } 126 + }) { 127 + Label("Play Next", systemImage: "text.insert") 128 + } 129 + 130 + Button(action: { 131 + Task { 132 + do { 133 + // Add to playlist 134 + } catch { 135 + errorText = String(describing: error) 136 + } 137 + } 138 + }) { 139 + Label("Add to Playlist", systemImage: "text.append") 140 + } 141 + 142 + Button(action: { 143 + Task { 144 + do { 145 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertLast)) 146 + await player.fetchQueue() 147 + } catch { 148 + errorText = String(describing: error) 149 + } 150 + } 151 + }) { 152 + Label("Play Last", systemImage: "text.append") 153 + } 154 + 155 + Divider() 156 + 157 + Button(action: { 158 + library.toggleLike(song) 159 + }) { 160 + Label(library.isLiked(song) ? "Remove from Liked" : "Add to Liked", 161 + systemImage: library.isLiked(song) ? "heart.slash" : "heart") 162 + } 163 + 164 + Divider() 165 + 166 + Button(action: { 167 + // Go to album action 168 + }) { 169 + Label("Go to Album", systemImage: "square.stack") 170 + } 171 + 172 + Button(action: { 173 + // Go to artist action 174 + }) { 175 + Label("Go to Artist", systemImage: "music.mic") 176 + 177 + } 178 + } label: { 179 + Image(systemName: "ellipsis") 180 + .font(.system(size: 14)) 181 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 182 + .frame(width: 32, height: 32) 183 + .contentShape(Rectangle()) 184 + } 185 + .menuStyle(.borderlessButton) 186 + .menuIndicator(.hidden) 187 + .frame(width: 40, alignment: .center) 188 + .opacity(isHovering ? 1 : 0) 189 + .onHover { hovering in 190 + isHoveringMenu = hovering 191 + } 113 192 } 114 193 .font(.system(size: 12)) 115 194 .padding(.horizontal, 16) ··· 120 199 isHovering = hovering 121 200 } 122 201 } 202 + .alert("Error", isPresented: .constant(errorText != nil)) { 203 + Button("OK") { errorText = nil } 204 + } message: { 205 + Text(errorText ?? "") 206 + } 123 207 } 124 208 125 209 private func formatDuration(_ duration: TimeInterval) -> String { ··· 128 212 return String(format: "%d:%02d", minutes, seconds) 129 213 } 130 214 } 131 - 132 -
+2 -2
macos/Rockbox/Views/Songs/SongsListView.swift
··· 67 67 let data = try await fetchTracks() 68 68 songs = [] 69 69 for track in data { 70 - songs.append(Song(cuid: track.id, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3))) 70 + songs.append(Song(cuid: track.id, path: track.path, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3))) 71 71 } 72 72 73 73 let likes = try await fetchLikedTracks() 74 74 for track in likes { 75 - let song = Song(cuid: track.id, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3)) 75 + let song = Song(cuid: track.id, path: track.path, title: track.title, artist: track.artist, album: track.album, albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), duration: TimeInterval(track.length / 1000), trackNumber: Int(track.trackNumber), discNumber: Int(track.discNumber), color: .gray.opacity(0.3)) 76 76 library.likedSongIds.insert(song.cuid) 77 77 } 78 78