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

Add rebuild_index flag and macOS search UI

+980 -155
+2
cli/src/api/rockbox.v1alpha1.rs
··· 501 501 pub struct ScanLibraryRequest { 502 502 #[prost(string, optional, tag = "1")] 503 503 pub path: ::core::option::Option<::prost::alloc::string::String>, 504 + #[prost(bool, optional, tag = "2")] 505 + pub rebuild_index: ::core::option::Option<bool>, 504 506 } 505 507 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 506 508 pub struct ScanLibraryResponse {}
cli/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+5 -2
cli/src/cmd/scan.rs
··· 8 8 9 9 use super::start::*; 10 10 11 - pub async fn scan(path: Option<String>) -> Result<(), Error> { 11 + pub async fn scan(path: Option<String>, rebuild_index: Option<bool>) -> Result<(), Error> { 12 12 install_rockboxd()?; 13 13 let handle = thread::spawn(|| match start(false) { 14 14 Ok(_) => {} ··· 24 24 25 25 let url = format!("tcp://{}:{}", host, port); 26 26 let mut client = LibraryServiceClient::connect(url).await?; 27 - let request = tonic::Request::new(ScanLibraryRequest { path }); 27 + let request = tonic::Request::new(ScanLibraryRequest { 28 + path, 29 + rebuild_index, 30 + }); 28 31 client.scan_library(request).await?; 29 32 println!("Scan request sent to Rockbox server"); 30 33 handle.join().unwrap();
+5
cli/src/cmd/start.rs
··· 31 31 .env("ROCKBOX_PORT", port) 32 32 .env("ROCKBOX_GRAPHQL_PORT", ui_port) 33 33 .env("ROCKBOX_TCP_PORT", http_port) 34 + .env("ROCKBOX_UPDATE_LIBRARY", env.var("ROCKBOX_UPDATE_LIBRARY")) 34 35 .spawn()?; 35 36 36 37 child.wait()?; ··· 45 46 .env("ROCKBOX_PORT", port) 46 47 .env("ROCKBOX_GRAPHQL_PORT", ui_port) 47 48 .env("ROCKBOX_TCP_PORT", http_port) 49 + .env( 50 + "ROCKBOX_UPDATE_LIBRARY", 51 + env::var("ROCKBOX_UPDATE_LIBRARY").unwrap_or("0".to_string()), 52 + ) 48 53 .spawn()?; 49 54 50 55 child.wait()?;
+18 -4
cli/src/main.rs
··· 1 - use std::ffi::OsString; 1 + use std::{env, ffi::OsString}; 2 2 3 3 use anyhow::Error; 4 4 use clap::{arg, Command}; ··· 30 30 Command::new("rockbox") 31 31 .version(VERSION) 32 32 .about(&banner) 33 + .arg(arg!(--rebuild -r "Rebuild index after scan")) 33 34 .subcommand( 34 35 Command::new("scan") 35 36 .arg(arg!(--directory -d [PATH] "path to your music library").required(false)) 37 + .arg(arg!(--rebuild -r "Rebuild index after scan")) 36 38 .about("Scan your music library for new media files"), 37 39 ) 38 40 .subcommand( 39 41 Command::new("community").about("Join our community on Discord to chat with us!"), 40 42 ) 41 - .subcommand(Command::new("start").about("Start Rockbox server")) 43 + .subcommand( 44 + Command::new("start") 45 + .about("Start Rockbox server") 46 + .arg(arg!(--rebuild -r "Rebuild index after scan")), 47 + ) 42 48 .subcommand(Command::new("tui").about("Start Rockbox TUI")) 43 49 .subcommand( 44 50 Command::new("webui") ··· 101 107 match matches.subcommand() { 102 108 Some(("scan", args)) => { 103 109 let directory = args.get_one::<String>("directory").map(|d| d.to_string()); 104 - scan(directory).await?; 110 + let rebuild_index = match args.get_flag("rebuild") { 111 + true => Some(true), 112 + false => None, 113 + }; 114 + scan(directory, rebuild_index).await?; 105 115 } 106 116 Some(("community", _)) => { 107 117 community(); ··· 139 149 Some(("whoami", _)) => { 140 150 whoami().await?; 141 151 } 142 - _ => { 152 + Some((_, args)) => { 153 + if args.get_flag("rebuild") { 154 + env::set_var("ROCKBOX_UPDATE_LIBRARY", "1"); 155 + } 143 156 start(true)?; 144 157 } 158 + None => start(true)?, 145 159 } 146 160 Ok(()) 147 161 }
+2
crates/controls/src/api/rockbox.v1alpha1.rs
··· 501 501 pub struct ScanLibraryRequest { 502 502 #[prost(string, optional, tag = "1")] 503 503 pub path: ::core::option::Option<::prost::alloc::string::String>, 504 + #[prost(bool, optional, tag = "2")] 505 + pub rebuild_index: ::core::option::Option<bool>, 504 506 } 505 507 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 506 508 pub struct ScanLibraryResponse {}
crates/controls/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

-1
crates/library/src/audio_scan.rs
··· 1 1 use crate::album_art::extract_and_save_album_cover; 2 - use crate::artists::update_metadata; 3 2 use crate::copyright_message::extract_copyright_message; 4 3 use crate::entity::album::Album; 5 4 use crate::entity::album_tracks::AlbumTracks;
+4 -1
crates/mpd/src/handlers/library.rs
··· 181 181 false => format!("{}/{}", response.music_dir, path), 182 182 }); 183 183 ctx.library 184 - .scan_library(ScanLibraryRequest { path }) 184 + .scan_library(ScanLibraryRequest { 185 + path, 186 + rebuild_index: None, 187 + }) 185 188 .await?; 186 189 187 190 if !ctx.batch {
+2
crates/rocksky/src/api/rockbox.v1alpha1.rs
··· 501 501 pub struct ScanLibraryRequest { 502 502 #[prost(string, optional, tag = "1")] 503 503 pub path: ::core::option::Option<::prost::alloc::string::String>, 504 + #[prost(bool, optional, tag = "2")] 505 + pub rebuild_index: ::core::option::Option<bool>, 504 506 } 505 507 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 506 508 pub struct ScanLibraryResponse {}
crates/rocksky/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+1
crates/rpc/proto/rockbox/v1alpha1/library.proto
··· 132 132 133 133 message ScanLibraryRequest { 134 134 optional string path = 1; 135 + optional bool rebuild_index = 2; 135 136 } 136 137 137 138 message ScanLibraryResponse {}
+2
crates/rpc/src/api/rockbox.v1alpha1.rs
··· 1028 1028 pub struct ScanLibraryRequest { 1029 1029 #[prost(string, optional, tag = "1")] 1030 1030 pub path: ::core::option::Option<::prost::alloc::string::String>, 1031 + #[prost(bool, optional, tag = "2")] 1032 + pub rebuild_index: ::core::option::Option<bool>, 1031 1033 } 1032 1034 #[derive(Clone, Copy, PartialEq, ::prost::Message)] 1033 1035 pub struct ScanLibraryResponse {}
crates/rpc/src/api/rockbox_descriptor.bin

This is a binary file and will not be displayed.

+7 -3
crates/rpc/src/library.rs
··· 253 253 request: tonic::Request<ScanLibraryRequest>, 254 254 ) -> Result<tonic::Response<ScanLibraryResponse>, tonic::Status> { 255 255 let request = request.into_inner(); 256 - let params = match request.path { 257 - Some(path) => format!("?path={}", path), 258 - None => "".to_string(), 256 + let params = match (request.path, request.rebuild_index) { 257 + (Some(path), Some(true)) => format!("?path={}&rebuild_index=1", path), 258 + (Some(path), Some(false)) => format!("?path={}", path), 259 + (None, Some(true)) => format!("?rebuild_index=1"), 260 + (None, Some(false)) => "".to_string(), 261 + (Some(path), None) => format!("?path={}", path), 262 + (None, None) => "".to_string(), 259 263 }; 260 264 let url = format!("{}/scan-library{}", rockbox_url(), params); 261 265 self.client
+61 -2
crates/server/src/handlers/system.rs
··· 2 2 3 3 use crate::http::{Context, Request, Response}; 4 4 use anyhow::Error; 5 + use owo_colors::OwoColorize; 5 6 use rockbox_library::{artists::update_metadata, audio_scan::scan_audio_files, repo}; 6 7 use rockbox_search::{ 7 - album::Album, artist::Artist, delete_all_documents, index_entity, liked_album::LikedAlbum, 8 - liked_track::LikedTrack, track::Track, 8 + album::Album, artist::Artist, create_indexes, delete_all_documents, index_entity, 9 + liked_album::LikedAlbum, liked_track::LikedTrack, track::Track, 9 10 }; 10 11 use rockbox_sys as rb; 11 12 ··· 36 37 37 38 scan_audio_files(ctx.pool.clone(), path.into()).await?; 38 39 40 + let rebuild_index = match req.query_params.get("rebuild_index") { 41 + Some(rebuild_index) => { 42 + let rebuild_index = rebuild_index.as_str().unwrap_or("false"); 43 + rebuild_index == "true" || rebuild_index == "1" 44 + } 45 + None => false, 46 + }; 47 + 39 48 if path != music_library { 40 49 res.text("0"); 41 50 return Ok(()); ··· 43 52 44 53 update_metadata(ctx.pool.clone())?; 45 54 55 + if !rebuild_index { 56 + res.text("0"); 57 + return Ok(()); 58 + } 59 + 46 60 let tracks = repo::track::all(ctx.pool.clone()).await?; 47 61 let albums = repo::album::all(ctx.pool.clone()).await?; 48 62 let artists = repo::artist::all(ctx.pool.clone()).await?; ··· 60 74 Ok(_) => {} 61 75 Err(e) => eprintln!("Error deleting all documents: {:?}", e), 62 76 } 77 + let mut i = 1; 78 + let len = tracks.len(); 63 79 for track in tracks { 80 + println!( 81 + "[{}/{}] Indexing track: {}", 82 + i, 83 + len, 84 + track.title.bright_green() 85 + ); 64 86 index_entity::<Track>(&tracks_index, &track.into()).unwrap(); 87 + i += 1; 65 88 } 66 89 }); 67 90 ··· 70 93 Ok(_) => {} 71 94 Err(e) => eprintln!("Error deleting all documents: {:?}", e), 72 95 } 96 + let mut i = 1; 97 + let len = albums.len(); 73 98 for album in albums { 99 + println!( 100 + "[{}/{}] Indexing album: {}", 101 + i, 102 + len, 103 + album.title.bright_green() 104 + ); 74 105 index_entity::<Album>(&albums_index, &album.into()).unwrap(); 106 + i += 1; 75 107 } 76 108 }); 77 109 ··· 80 112 Ok(_) => {} 81 113 Err(e) => eprintln!("Error deleting all documents: {:?}", e), 82 114 } 115 + let mut i = 1; 116 + let len = artists.len(); 83 117 for artist in artists { 118 + println!( 119 + "[{}/{}] Indexing artist: {}", 120 + i, 121 + len, 122 + artist.name.bright_green() 123 + ); 84 124 index_entity::<Artist>(&artists_index, &artist.into()).unwrap(); 125 + i += 1; 85 126 } 86 127 }); 87 128 ··· 90 131 Ok(_) => {} 91 132 Err(e) => eprintln!("Error deleting all documents: {:?}", e), 92 133 } 134 + let mut i = 1; 135 + let len = liked_albums.len(); 93 136 for liked_album in liked_albums { 137 + println!( 138 + "[{}/{}] Indexing liked album: {}", 139 + i, 140 + len, 141 + liked_album.title.bright_green() 142 + ); 94 143 index_entity::<LikedAlbum>(&liked_albums_index, &liked_album.into()).unwrap(); 144 + i += 1; 95 145 } 96 146 }); 97 147 ··· 100 150 Ok(_) => {} 101 151 Err(e) => eprintln!("Error deleting all documents: {:?}", e), 102 152 } 153 + let mut i = 1; 154 + let len = liked_tracks.len(); 103 155 for liked_track in liked_tracks { 156 + println!( 157 + "[{}/{}] Indexing liked track: {}", 158 + i, 159 + len, 160 + liked_track.title.bright_green() 161 + ); 104 162 index_entity::<LikedTrack>(&liked_tracks_index, &liked_track.into()).unwrap(); 163 + i += 1; 105 164 } 106 165 }); 107 166
macos/Rockbox.xcodeproj/project.xcworkspace/xcuserdata/tsirysandratraina.xcuserdatad/UserInterfaceState.xcuserstate

This is a binary file and will not be displayed.

+2
macos/Rockbox/RockboxApp.swift
··· 11 11 struct RockboxApp: App { 12 12 @StateObject private var player = PlayerState() 13 13 @StateObject private var navigation = NavigationManager() 14 + @StateObject private var searchManager = SearchManager() 14 15 15 16 var body: some Scene { 16 17 WindowGroup { 17 18 ContentView() 18 19 .environmentObject(player) 19 20 .environmentObject(navigation) 21 + .environmentObject(searchManager) 20 22 .task { 21 23 player.startStreaming() 22 24 }
+2 -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 12 + func searchTrack(query: String, host: String = "127.0.0.1", port: Int = 6061) async throws 13 13 -> Rockbox_V1alpha1_SearchResponse 14 14 { 15 15 try await withGRPCClient( ··· 21 21 let library = Rockbox_V1alpha1_LibraryService.Client(wrapping: grpcClient) 22 22 23 23 var req = Rockbox_V1alpha1_SearchRequest() 24 - req.term = term 24 + req.term = query 25 25 26 26 let res = try await library.search(req) 27 27
+16 -1
macos/Rockbox/Services/rockbox/v1alpha1/library.pb.swift
··· 580 580 /// Clears the value of `path`. Subsequent reads from it will return its default value. 581 581 mutating func clearPath() {self._path = nil} 582 582 583 + var rebuildIndex: Bool { 584 + get {return _rebuildIndex ?? false} 585 + set {_rebuildIndex = newValue} 586 + } 587 + /// Returns true if `rebuildIndex` has been explicitly set. 588 + var hasRebuildIndex: Bool {return self._rebuildIndex != nil} 589 + /// Clears the value of `rebuildIndex`. Subsequent reads from it will return its default value. 590 + mutating func clearRebuildIndex() {self._rebuildIndex = nil} 591 + 583 592 var unknownFields = SwiftProtobuf.UnknownStorage() 584 593 585 594 init() {} 586 595 587 596 fileprivate var _path: String? = nil 597 + fileprivate var _rebuildIndex: Bool? = nil 588 598 } 589 599 590 600 struct Rockbox_V1alpha1_ScanLibraryResponse: Sendable { ··· 1636 1646 1637 1647 extension Rockbox_V1alpha1_ScanLibraryRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 1638 1648 static let protoMessageName: String = _protobuf_package + ".ScanLibraryRequest" 1639 - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0") 1649 + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{3}rebuild_index\0") 1640 1650 1641 1651 mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { 1642 1652 while let fieldNumber = try decoder.nextFieldNumber() { ··· 1645 1655 // enabled. https://github.com/apple/swift-protobuf/issues/1034 1646 1656 switch fieldNumber { 1647 1657 case 1: try { try decoder.decodeSingularStringField(value: &self._path) }() 1658 + case 2: try { try decoder.decodeSingularBoolField(value: &self._rebuildIndex) }() 1648 1659 default: break 1649 1660 } 1650 1661 } ··· 1658 1669 try { if let v = self._path { 1659 1670 try visitor.visitSingularStringField(value: v, fieldNumber: 1) 1660 1671 } }() 1672 + try { if let v = self._rebuildIndex { 1673 + try visitor.visitSingularBoolField(value: v, fieldNumber: 2) 1674 + } }() 1661 1675 try unknownFields.traverse(visitor: &visitor) 1662 1676 } 1663 1677 1664 1678 static func ==(lhs: Rockbox_V1alpha1_ScanLibraryRequest, rhs: Rockbox_V1alpha1_ScanLibraryRequest) -> Bool { 1665 1679 if lhs._path != rhs._path {return false} 1680 + if lhs._rebuildIndex != rhs._rebuildIndex {return false} 1666 1681 if lhs.unknownFields != rhs.unknownFields {return false} 1667 1682 return true 1668 1683 }
+116
macos/Rockbox/State/SearchManager.swift
··· 1 + // 2 + // SearchManager.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 22/12/2025. 6 + // 7 + 8 + import SwiftUI 9 + 10 + @MainActor 11 + class SearchManager: ObservableObject { 12 + @Published var searchText: String = "" 13 + @Published var isSearching: Bool = false 14 + @Published var searchResults: SearchResults = SearchResults() 15 + @Published var isLoading: Bool = false 16 + 17 + private var searchTask: Task<Void, Never>? 18 + 19 + struct SearchResults { 20 + var songs: [Song] = [] 21 + var albums: [Album] = [] 22 + var artists: [Artist] = [] 23 + 24 + var isEmpty: Bool { 25 + songs.isEmpty && albums.isEmpty && artists.isEmpty 26 + } 27 + 28 + var totalCount: Int { 29 + songs.count + albums.count + artists.count 30 + } 31 + } 32 + 33 + func search() { 34 + searchTask?.cancel() 35 + 36 + guard !searchText.trimmingCharacters(in: .whitespaces).isEmpty else { 37 + searchResults = SearchResults() 38 + isSearching = false 39 + return 40 + } 41 + 42 + isSearching = true 43 + isLoading = true 44 + 45 + searchTask = Task { 46 + // Debounce 47 + try? await Task.sleep(for: .milliseconds(300)) 48 + 49 + guard !Task.isCancelled else { return } 50 + 51 + do { 52 + let results = try await searchTrack(query: searchText) 53 + 54 + guard !Task.isCancelled else { return } 55 + 56 + searchResults = SearchResults( 57 + songs: results.tracks.map { track in 58 + Song( 59 + cuid: track.id, 60 + path: track.path, 61 + title: track.title, 62 + artist: track.artist, 63 + album: track.album, 64 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 65 + duration: TimeInterval(track.length / 1000), 66 + trackNumber: Int(track.trackNumber), 67 + discNumber: Int(track.discNumber), 68 + albumID: track.albumID, 69 + artistID: track.artistID, 70 + color: .gray.opacity(0.3) 71 + ) 72 + }, 73 + albums: results.albums.map { album in 74 + Album( 75 + cuid: album.id, 76 + title: album.title, 77 + artist: album.artist, 78 + year: Int(album.year), 79 + color: .gray.opacity(0.3), 80 + cover: "http://localhost:6062/covers/" + album.albumArt, 81 + releaseDate: album.yearString, 82 + copyrightMessage: album.copyrightMessage, 83 + artistID: album.artistID, 84 + tracks: [] 85 + ) 86 + }, 87 + artists: results.artists.map { artist in 88 + Artist( 89 + cuid: artist.id, 90 + name: artist.name, 91 + image: artist.image, 92 + genre: artist.genres, 93 + color: .gray.opacity(0.3) 94 + ) 95 + } 96 + ) 97 + 98 + isLoading = false 99 + } catch { 100 + if !Task.isCancelled { 101 + isLoading = false 102 + print("Search error: \(error)") 103 + } 104 + } 105 + } 106 + } 107 + 108 + func clear() { 109 + searchText = "" 110 + searchResults = SearchResults() 111 + isSearching = false 112 + isLoading = false 113 + searchTask?.cancel() 114 + } 115 + } 116 +
+66 -60
macos/Rockbox/Views/Components/Sidebar.swift
··· 8 8 import SwiftUI 9 9 10 10 struct Sidebar: View { 11 - @Binding var selection: SidebarItem? 12 - @State private var searchText = "" 13 - 14 - var body: some View { 15 - ZStack { 16 - VisualEffectView(material: .sidebar) 17 - .ignoresSafeArea() 11 + @Binding var selection: SidebarItem? 12 + @EnvironmentObject var searchManager: SearchManager 18 13 19 - VStack(spacing: 0) { 20 - // Search input 21 - HStack(spacing: 8) { 22 - Image(systemName: "magnifyingglass") 23 - .font(.system(size: 12)) 24 - .foregroundStyle(.secondary) 25 - 26 - TextField("Search", text: $searchText) 27 - .textFieldStyle(.plain) 28 - .font(.system(size: 13)) 29 - 30 - if !searchText.isEmpty { 31 - Button(action: { searchText = "" }) { 32 - Image(systemName: "xmark.circle.fill") 33 - .font(.system(size: 12)) 34 - .foregroundStyle(.secondary) 35 - } 36 - .buttonStyle(.plain) 37 - } 38 - } 39 - .padding(.horizontal, 10) 40 - .padding(.vertical, 6) 41 - .background(Color.black.opacity(0.05)) 42 - .cornerRadius(8) 43 - .padding(.horizontal, 12) 44 - .padding(.top, 12) 45 - .padding(.bottom, 8) 46 - 47 - List(selection: $selection) { 48 - Section("Library") { 49 - ForEach(SidebarItem.allCases) { item in 50 - Label(item.rawValue, systemImage: item.icon) 51 - .tag(item) 52 - } 53 - } 54 - } 55 - .listStyle(.sidebar) 56 - .scrollContentBackground(.hidden) 14 + var body: some View { 15 + ZStack { 16 + VisualEffectView(material: .sidebar) 17 + .ignoresSafeArea() 18 + 19 + VStack(spacing: 0) { 20 + // Search input 21 + HStack(spacing: 8) { 22 + Image(systemName: "magnifyingglass") 23 + .font(.system(size: 12)) 24 + .foregroundStyle(.secondary) 25 + 26 + TextField("Search", text: $searchManager.searchText) 27 + .textFieldStyle(.plain) 28 + .font(.system(size: 13)) 29 + .onSubmit { 30 + searchManager.search() 31 + } 32 + 33 + if !searchManager.searchText.isEmpty { 34 + Button(action: { searchManager.clear() }) { 35 + Image(systemName: "xmark.circle.fill") 36 + .font(.system(size: 12)) 37 + .foregroundStyle(.secondary) 57 38 } 39 + .buttonStyle(.plain) 40 + } 58 41 } 42 + .padding(.horizontal, 10) 43 + .padding(.vertical, 6) 44 + .background(Color.black.opacity(0.05)) 45 + .cornerRadius(8) 46 + .padding(.horizontal, 12) 47 + .padding(.top, 12) 48 + .padding(.bottom, 8) 49 + .onChange(of: searchManager.searchText) { 50 + searchManager.search() 51 + } 52 + 53 + List(selection: $selection) { 54 + Section("Library") { 55 + ForEach(SidebarItem.allCases) { item in 56 + Label(item.rawValue, systemImage: item.icon) 57 + .tag(item) 58 + } 59 + } 60 + } 61 + .listStyle(.sidebar) 62 + .scrollContentBackground(.hidden) 63 + } 59 64 } 65 + } 60 66 } 61 67 62 68 struct VisualEffectView: NSViewRepresentable { 63 - var material: NSVisualEffectView.Material = .sidebar 64 - var blendingMode: NSVisualEffectView.BlendingMode = .behindWindow 65 - var state: NSVisualEffectView.State = .active 69 + var material: NSVisualEffectView.Material = .sidebar 70 + var blendingMode: NSVisualEffectView.BlendingMode = .behindWindow 71 + var state: NSVisualEffectView.State = .active 66 72 67 - func makeNSView(context: Context) -> NSVisualEffectView { 68 - let v = NSVisualEffectView() 69 - v.material = material 70 - v.blendingMode = blendingMode 71 - v.state = state 72 - return v 73 - } 73 + func makeNSView(context: Context) -> NSVisualEffectView { 74 + let v = NSVisualEffectView() 75 + v.material = material 76 + v.blendingMode = blendingMode 77 + v.state = state 78 + return v 79 + } 74 80 75 - func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 76 - nsView.material = material 77 - nsView.blendingMode = blendingMode 78 - nsView.state = state 79 - } 81 + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 82 + nsView.material = material 83 + nsView.blendingMode = blendingMode 84 + nsView.state = state 85 + } 80 86 }
+84 -79
macos/Rockbox/Views/Main/DetailView.swift
··· 7 7 import SwiftUI 8 8 9 9 struct DetailView: View { 10 - let selection: SidebarItem? 11 - @ObservedObject var player: PlayerState 12 - @ObservedObject var library: MusicLibrary 13 - @EnvironmentObject var navigation: NavigationManager 14 - @Binding var showQueue: Bool 15 - 16 - var body: some View { 17 - VStack(spacing: 0) { 18 - // Main content area 19 - contentView 20 - .background(.white) 21 - 22 - Divider() 23 - 24 - // Player controls 25 - PlayerControlsView(library: library, showQueue: $showQueue) 10 + let selection: SidebarItem? 11 + @ObservedObject var player: PlayerState 12 + @ObservedObject var library: MusicLibrary 13 + @EnvironmentObject var navigation: NavigationManager 14 + @EnvironmentObject var searchManager: SearchManager 15 + @Binding var showQueue: Bool 16 + 17 + var body: some View { 18 + VStack(spacing: 0) { 19 + // Main content area 20 + contentView 21 + .background(.white) 22 + 23 + Divider() 24 + 25 + // Player controls 26 + PlayerControlsView(library: library, showQueue: $showQueue) 27 + } 28 + .frame(maxWidth: .infinity, maxHeight: .infinity) 29 + .onChange(of: selection) { 30 + navigation.reset() 31 + } 32 + .task { 33 + do { 34 + let likes = try await fetchLikedTracks() 35 + for track in likes { 36 + let song = Song( 37 + cuid: track.id, 38 + path: track.path, 39 + title: track.title, 40 + artist: track.artist, 41 + album: track.album, 42 + albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 43 + duration: TimeInterval(track.length / 1000), 44 + trackNumber: Int(track.trackNumber), 45 + discNumber: Int(track.discNumber), 46 + albumID: track.albumID, 47 + artistID: track.artistID, 48 + color: .gray.opacity(0.3)) 49 + library.likedSongIds.insert(song.cuid) 26 50 } 51 + } catch { 52 + // do nothing on error 53 + } 54 + } 55 + } 56 + 57 + @ViewBuilder 58 + private var contentView: some View { 59 + if searchManager.isSearching { 60 + SearchResultsView(library: library) 61 + } else if let album = navigation.selectedAlbum { 62 + AlbumDetailView( 63 + album: album, library: library, 64 + onBack: { 65 + navigation.selectedAlbum = nil 66 + }) 67 + } else if let artist = navigation.selectedArtist { 68 + ArtistDetailView( 69 + artist: artist, 70 + library: library, 71 + onBack: { navigation.selectedArtist = nil }, 72 + onAlbumSelected: { album in navigation.goToAlbum(album) } 73 + ) 74 + } else if let selection { 75 + selectionView(for: selection) 76 + } else { 77 + Text("Select an item") 78 + .foregroundStyle(.secondary) 27 79 .frame(maxWidth: .infinity, maxHeight: .infinity) 28 - .onChange(of: selection) { 29 - navigation.reset() 30 - } 31 - .task { 32 - do { 33 - let likes = try await fetchLikedTracks() 34 - for track in likes { 35 - let song = Song( 36 - cuid: track.id, 37 - path: track.path, 38 - title: track.title, 39 - artist: track.artist, 40 - album: track.album, 41 - albumArt: URL(string: "http://localhost:6062/covers/" + track.albumArt), 42 - duration: TimeInterval(track.length / 1000), 43 - trackNumber: Int(track.trackNumber), 44 - discNumber: Int(track.discNumber), 45 - albumID: track.albumID, 46 - artistID: track.artistID, 47 - color: .gray.opacity(0.3)) 48 - library.likedSongIds.insert(song.cuid) 49 - } 50 - } catch { 51 - // do nothing on error 52 - } 53 - } 54 80 } 55 - 56 - @ViewBuilder 57 - private var contentView: some View { 58 - if let album = navigation.selectedAlbum { 59 - AlbumDetailView(album: album, library: library, onBack: { 60 - navigation.selectedAlbum = nil 61 - }) 62 - } else if let artist = navigation.selectedArtist { 63 - ArtistDetailView( 64 - artist: artist, 65 - library: library, 66 - onBack: { navigation.selectedArtist = nil }, 67 - onAlbumSelected: { album in navigation.goToAlbum(album) } 68 - ) 69 - } else if let selection { 70 - selectionView(for: selection) 71 - } else { 72 - Text("Select an item") 73 - .foregroundStyle(.secondary) 74 - .frame(maxWidth: .infinity, maxHeight: .infinity) 75 - } 76 - } 77 - 78 - @ViewBuilder 79 - private func selectionView(for selection: SidebarItem) -> some View { 80 - switch selection { 81 - case .albums: 82 - AlbumsGridView(selectedAlbum: $navigation.selectedAlbum) 83 - case .artists: 84 - ArtistsGridView(selectedArtist: $navigation.selectedArtist) 85 - case .songs: 86 - SongsListView(library: library) 87 - case .likes: 88 - LikesListView(library: library) 89 - case .files: 90 - FilesListView() 91 - } 81 + } 82 + 83 + @ViewBuilder 84 + private func selectionView(for selection: SidebarItem) -> some View { 85 + switch selection { 86 + case .albums: 87 + AlbumsGridView(selectedAlbum: $navigation.selectedAlbum) 88 + case .artists: 89 + ArtistsGridView(selectedArtist: $navigation.selectedArtist) 90 + case .songs: 91 + SongsListView(library: library) 92 + case .likes: 93 + LikesListView(library: library) 94 + case .files: 95 + FilesListView() 92 96 } 97 + } 93 98 }
+585
macos/Rockbox/Views/Search/SearchResultsView.swift
··· 1 + // 2 + // SearchResultsView.swift 3 + // Rockbox 4 + // 5 + // Created by Tsiry Sandratraina on 22/12/2025. 6 + // 7 + 8 + import SwiftUI 9 + 10 + struct SearchResultsView: View { 11 + @EnvironmentObject var searchManager: SearchManager 12 + @EnvironmentObject var navigation: NavigationManager 13 + @EnvironmentObject var player: PlayerState 14 + @ObservedObject var library: MusicLibrary 15 + var playlists: [Playlist] = [] 16 + 17 + var body: some View { 18 + ScrollView { 19 + VStack(alignment: .leading, spacing: 24) { 20 + if searchManager.isLoading { 21 + HStack { 22 + Spacer() 23 + ProgressView() 24 + Spacer() 25 + } 26 + .padding(.top, 40) 27 + } else if searchManager.searchResults.isEmpty { 28 + emptyResultsView 29 + } else { 30 + // Artists section 31 + if !searchManager.searchResults.artists.isEmpty { 32 + searchSection(title: "Artists") { 33 + ScrollView(.horizontal, showsIndicators: false) { 34 + HStack(spacing: 16) { 35 + ForEach(searchManager.searchResults.artists) { artist in 36 + SearchArtistCard(artist: artist) { 37 + navigation.goToArtist(artist) 38 + searchManager.clear() 39 + } 40 + } 41 + } 42 + .padding(.horizontal, 20) 43 + } 44 + } 45 + } 46 + 47 + // Albums section 48 + if !searchManager.searchResults.albums.isEmpty { 49 + searchSection(title: "Albums") { 50 + ScrollView(.horizontal, showsIndicators: false) { 51 + HStack(spacing: 16) { 52 + ForEach(searchManager.searchResults.albums) { album in 53 + SearchAlbumCard(album: album, playlists: playlists) { 54 + navigation.goToAlbum(album) 55 + searchManager.clear() 56 + } 57 + } 58 + } 59 + .padding(.horizontal, 20) 60 + } 61 + } 62 + } 63 + 64 + // Songs section 65 + if !searchManager.searchResults.songs.isEmpty { 66 + searchSection(title: "Songs") { 67 + LazyVStack(spacing: 0) { 68 + ForEach(Array(searchManager.searchResults.songs.prefix(10).enumerated()), id: \.element.id) { index, song in 69 + SearchSongRow( 70 + song: song, 71 + index: index, 72 + library: library, 73 + playlists: playlists 74 + ) 75 + } 76 + } 77 + .padding(.horizontal, 20) 78 + } 79 + } 80 + } 81 + } 82 + .padding(.vertical, 20) 83 + } 84 + } 85 + 86 + private var emptyResultsView: some View { 87 + VStack(spacing: 12) { 88 + Image(systemName: "magnifyingglass") 89 + .font(.system(size: 48)) 90 + .foregroundStyle(.tertiary) 91 + 92 + Text("No results found") 93 + .font(.title3) 94 + .foregroundStyle(.secondary) 95 + 96 + Text("Try searching for something else") 97 + .font(.subheadline) 98 + .foregroundStyle(.tertiary) 99 + } 100 + .frame(maxWidth: .infinity, maxHeight: .infinity) 101 + .padding(.top, 60) 102 + } 103 + 104 + private func searchSection<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { 105 + VStack(alignment: .leading, spacing: 12) { 106 + Text(title) 107 + .font(.title2.bold()) 108 + .padding(.horizontal, 20) 109 + 110 + content() 111 + } 112 + } 113 + } 114 + 115 + // MARK: - Search Artist Card 116 + 117 + struct SearchArtistCard: View { 118 + let artist: Artist 119 + let onSelect: () -> Void 120 + 121 + @State private var isHovering = false 122 + @State private var isHoveringPlay = false 123 + @State private var errorText: String? = nil 124 + @EnvironmentObject var player: PlayerState 125 + 126 + var body: some View { 127 + VStack(spacing: 8) { 128 + Circle() 129 + .fill(artist.color.gradient) 130 + .frame(width: 120, height: 120) 131 + .overlay { 132 + if let imageUrl = artist.image { 133 + CachedAsyncImage(url: URL(string: imageUrl)) { phase in 134 + switch phase { 135 + case .success(let image): 136 + image 137 + .resizable() 138 + .aspectRatio(contentMode: .fill) 139 + default: 140 + Image(systemName: "music.mic") 141 + .font(.system(size: 32)) 142 + .foregroundStyle(.white.opacity(0.6)) 143 + } 144 + } 145 + } else { 146 + Image(systemName: "music.mic") 147 + .font(.system(size: 32)) 148 + .foregroundStyle(.white.opacity(0.6)) 149 + } 150 + } 151 + .overlay { 152 + // Floating play button 153 + if isHovering { 154 + Button(action: { 155 + Task { 156 + do { 157 + try await playArtistTracks(artistID: artist.cuid) 158 + await player.fetchQueue() 159 + } catch { 160 + errorText = String(describing: error) 161 + } 162 + } 163 + }) { 164 + Circle() 165 + .fill(isHoveringPlay ? Color(hex: "fe09a3") : .white.opacity(0.3)) 166 + .frame(width: 40, height: 40) 167 + .overlay { 168 + Image(systemName: "play.fill") 169 + .font(.system(size: 16)) 170 + .foregroundStyle(.white) 171 + } 172 + } 173 + .buttonStyle(.borderless) 174 + .onHover { isHoveringPlay = $0 } 175 + .transition(.scale.combined(with: .opacity)) 176 + } 177 + } 178 + .clipShape(Circle()) 179 + .scaleEffect(isHovering ? 1.05 : 1.0) 180 + .onHover { hovering in 181 + withAnimation(.easeInOut(duration: 0.2)) { 182 + isHovering = hovering 183 + } 184 + } 185 + 186 + Text(artist.name) 187 + .font(.system(size: 12, weight: .medium)) 188 + .lineLimit(1) 189 + 190 + Text("Artist") 191 + .font(.system(size: 11)) 192 + .foregroundStyle(.secondary) 193 + } 194 + .frame(width: 120) 195 + .onTapGesture { 196 + onSelect() 197 + } 198 + .alert("Error", isPresented: .constant(errorText != nil)) { 199 + Button("OK") { errorText = nil } 200 + } message: { 201 + Text(errorText ?? "") 202 + } 203 + } 204 + } 205 + 206 + // MARK: - Search Album Card 207 + 208 + struct SearchAlbumCard: View { 209 + let album: Album 210 + var playlists: [Playlist] = [] 211 + let onSelect: () -> Void 212 + 213 + @State private var isHovering = false 214 + @State private var isHoveringPlay = false 215 + @State private var isHoveringMenu = false 216 + @State private var showMenu = false 217 + @State private var errorText: String? = nil 218 + @EnvironmentObject var player: PlayerState 219 + 220 + var body: some View { 221 + VStack(alignment: .leading, spacing: 8) { 222 + RoundedRectangle(cornerRadius: 8) 223 + .fill(album.color.gradient) 224 + .frame(width: 140, height: 140) 225 + .overlay { 226 + CachedAsyncImage(url: URL(string: album.cover)) { phase in 227 + switch phase { 228 + case .success(let image): 229 + image 230 + .resizable() 231 + .aspectRatio(contentMode: .fill) 232 + default: 233 + Image(systemName: "music.note") 234 + .font(.system(size: 32)) 235 + .foregroundStyle(.white.opacity(0.6)) 236 + } 237 + } 238 + } 239 + .overlay(alignment: .bottom) { 240 + if isHovering || showMenu { 241 + HStack(spacing: 0) { 242 + // Play button 243 + Button(action: { 244 + Task { 245 + do { 246 + try await playAlbum(albumID: album.cuid) 247 + await player.fetchQueue() 248 + } catch { 249 + errorText = String(describing: error) 250 + } 251 + } 252 + }) { 253 + Circle() 254 + .fill(isHoveringPlay ? Color(hex: "fe09a3") : .white.opacity(0.3)) 255 + .frame(width: 32, height: 32) 256 + .overlay { 257 + Image(systemName: "play.fill") 258 + .font(.system(size: 12)) 259 + .foregroundStyle(.white) 260 + } 261 + } 262 + .buttonStyle(.borderless) 263 + .onHover { isHoveringPlay = $0 } 264 + .frame(maxWidth: .infinity, alignment: .center) 265 + 266 + // Context menu 267 + ZStack { 268 + Circle() 269 + .fill(isHoveringMenu || showMenu ? Color(hex: "fe09a3") : .white.opacity(0.3)) 270 + .frame(width: 32, height: 32) 271 + 272 + Image(systemName: "ellipsis") 273 + .font(.system(size: 12, weight: .medium)) 274 + .foregroundStyle(.white) 275 + .allowsHitTesting(false) 276 + 277 + Button(action: { showMenu.toggle() }) { 278 + Circle() 279 + .fill(Color.clear) 280 + .frame(width: 32, height: 32) 281 + } 282 + .buttonStyle(.borderless) 283 + .onHover { isHoveringMenu = $0 } 284 + .popover(isPresented: $showMenu, arrowEdge: .bottom) { 285 + ZStack { 286 + Color.white.ignoresSafeArea() 287 + 288 + VStack(alignment: .leading, spacing: 0) { 289 + MenuItemButton(title: "Play", icon: "play.fill") { 290 + showMenu = false 291 + Task { 292 + try? await playAlbum(albumID: album.cuid) 293 + await player.fetchQueue() 294 + } 295 + } 296 + 297 + MenuItemButton(title: "Play Shuffled", icon: "shuffle") { 298 + showMenu = false 299 + Task { 300 + try? await playAlbum(albumID: album.cuid, shuffle: true) 301 + await player.fetchQueue() 302 + } 303 + } 304 + 305 + Divider().padding(.vertical, 4) 306 + 307 + MenuItemButton(title: "Play Next", icon: "text.insert") { 308 + showMenu = false 309 + Task { 310 + try? await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertFirst)) 311 + await player.fetchQueue() 312 + } 313 + } 314 + 315 + MenuItemButton(title: "Play Last", icon: "text.append") { 316 + showMenu = false 317 + Task { 318 + try? await insertAlbum(albumID: album.cuid, position: Int32(PlaylistPosition.insertLast)) 319 + await player.fetchQueue() 320 + } 321 + } 322 + 323 + Divider().padding(.vertical, 4) 324 + 325 + MenuItemButton( 326 + title: "Add to Playlist", 327 + icon: "music.note.list", 328 + hasSubmenu: true, 329 + submenuItems: playlists, 330 + onSubmenuSelect: { playlist in 331 + showMenu = false 332 + }, 333 + onCreateNew: { 334 + showMenu = false 335 + }, 336 + action: {} 337 + ) 338 + } 339 + .padding(8) 340 + .frame(width: 200) 341 + } 342 + } 343 + } 344 + .frame(maxWidth: .infinity, alignment: .center) 345 + } 346 + .padding(.vertical, 10) 347 + .transition(.opacity.combined(with: .move(edge: .bottom))) 348 + } 349 + } 350 + .clipShape(RoundedRectangle(cornerRadius: 8)) 351 + .onTapGesture { 352 + onSelect() 353 + } 354 + .onHover { hovering in 355 + withAnimation(.easeInOut(duration: 0.2)) { 356 + if !showMenu { 357 + isHovering = hovering 358 + } 359 + } 360 + } 361 + .onChange(of: showMenu) { oldValue, newValue in 362 + if !newValue { 363 + withAnimation(.easeInOut(duration: 0.2)) { 364 + isHovering = false 365 + } 366 + } 367 + } 368 + 369 + VStack(alignment: .leading, spacing: 2) { 370 + Text(album.title) 371 + .font(.system(size: 12, weight: .medium)) 372 + .lineLimit(1) 373 + 374 + Text(album.artist) 375 + .font(.system(size: 11)) 376 + .foregroundStyle(.secondary) 377 + .lineLimit(1) 378 + } 379 + } 380 + .frame(width: 140) 381 + .alert("Error", isPresented: .constant(errorText != nil)) { 382 + Button("OK") { errorText = nil } 383 + } message: { 384 + Text(errorText ?? "") 385 + } 386 + } 387 + } 388 + 389 + // MARK: - Search Song Row 390 + 391 + struct SearchSongRow: View { 392 + let song: Song 393 + let index: Int 394 + @ObservedObject var library: MusicLibrary 395 + var playlists: [Playlist] = [] 396 + 397 + @State private var isHovering = false 398 + @State private var isHoveringPlay = false 399 + @State private var isHoveringMenu = false 400 + @State private var errorText: String? = nil 401 + @EnvironmentObject var player: PlayerState 402 + @EnvironmentObject var navigation: NavigationManager 403 + @EnvironmentObject var searchManager: SearchManager 404 + 405 + var body: some View { 406 + HStack(spacing: 12) { 407 + // Play button / index 408 + ZStack { 409 + Text("\(index + 1)") 410 + .font(.system(size: 12)) 411 + .foregroundStyle(.secondary) 412 + .opacity(isHovering ? 0 : 1) 413 + 414 + Button(action: { 415 + Task { 416 + do { 417 + try await playTrack(path: song.path) 418 + await player.fetchQueue() 419 + } catch { 420 + errorText = String(describing: error) 421 + } 422 + } 423 + }) { 424 + Image(systemName: "play.fill") 425 + .font(.system(size: 12)) 426 + .foregroundStyle(isHoveringPlay ? .primary : .secondary) 427 + } 428 + .buttonStyle(.plain) 429 + .opacity(isHovering ? 1 : 0) 430 + .onHover { isHoveringPlay = $0 } 431 + } 432 + .frame(width: 24) 433 + 434 + // Album art 435 + RoundedRectangle(cornerRadius: 4) 436 + .fill(song.color.gradient) 437 + .frame(width: 40, height: 40) 438 + .overlay { 439 + CachedAsyncImage(url: song.albumArt) { phase in 440 + switch phase { 441 + case .success(let image): 442 + image 443 + .resizable() 444 + .aspectRatio(contentMode: .fill) 445 + default: 446 + Image(systemName: "music.note") 447 + .font(.system(size: 12)) 448 + .foregroundStyle(.white.opacity(0.6)) 449 + } 450 + } 451 + } 452 + .clipShape(RoundedRectangle(cornerRadius: 4)) 453 + 454 + VStack(alignment: .leading, spacing: 2) { 455 + Text(song.title) 456 + .font(.system(size: 13)) 457 + .lineLimit(1) 458 + 459 + Text("\(song.artist) · \(song.album)") 460 + .font(.system(size: 11)) 461 + .foregroundStyle(.secondary) 462 + .lineLimit(1) 463 + } 464 + 465 + Spacer() 466 + 467 + // Duration 468 + Text(formatDuration(song.duration)) 469 + .font(.system(size: 12)) 470 + .foregroundStyle(.secondary) 471 + 472 + // Like button 473 + Button(action: { 474 + library.toggleLike(song) 475 + }) { 476 + Image(systemName: library.isLiked(song) ? "heart.fill" : "heart") 477 + .font(.system(size: 12)) 478 + .foregroundStyle(library.isLiked(song) ? Color(hex: "fe09a3") : .secondary) 479 + } 480 + .buttonStyle(.plain) 481 + .opacity(isHovering || library.isLiked(song) ? 1 : 0) 482 + 483 + // Context menu 484 + Menu { 485 + Button(action: { 486 + Task { 487 + do { 488 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertFirst)) 489 + await player.fetchQueue() 490 + } catch { 491 + errorText = String(describing: error) 492 + } 493 + } 494 + }) { 495 + Label("Play Next", systemImage: "text.insert") 496 + } 497 + 498 + Button(action: { 499 + Task { 500 + do { 501 + try await insertTracks(tracks: [song.path], position: Int32(PlaylistPosition.insertLast)) 502 + await player.fetchQueue() 503 + } catch { 504 + errorText = String(describing: error) 505 + } 506 + } 507 + }) { 508 + Label("Play Last", systemImage: "text.append") 509 + } 510 + 511 + Divider() 512 + 513 + MenuItemButton( 514 + title: "Add to Playlist", 515 + icon: "music.note.list", 516 + hasSubmenu: true, 517 + submenuItems: playlists, 518 + onSubmenuSelect: { playlist in 519 + // Add to playlist 520 + }, 521 + onCreateNew: { 522 + // Create new playlist 523 + }, 524 + action: {} 525 + ) 526 + 527 + Divider() 528 + 529 + Button(action: { 530 + library.toggleLike(song) 531 + }) { 532 + Label(library.isLiked(song) ? "Remove from Liked" : "Add to Liked", 533 + systemImage: library.isLiked(song) ? "heart.slash" : "heart") 534 + } 535 + 536 + Divider() 537 + 538 + Button(action: { 539 + Task { 540 + await navigation.goToAlbum(byId: song.albumID) 541 + searchManager.clear() 542 + } 543 + }) { 544 + Label("Go to Album", systemImage: "square.stack") 545 + } 546 + 547 + Button(action: { 548 + Task { 549 + await navigation.goToArtist(byId: song.artistID) 550 + searchManager.clear() 551 + } 552 + }) { 553 + Label("Go to Artist", systemImage: "music.mic") 554 + } 555 + } label: { 556 + Image(systemName: "ellipsis") 557 + .font(.system(size: 14)) 558 + .foregroundStyle(isHoveringMenu ? .primary : .secondary) 559 + .frame(width: 32, height: 32) 560 + .contentShape(Rectangle()) 561 + } 562 + .menuStyle(.borderlessButton) 563 + .menuIndicator(.hidden) 564 + .frame(width: 32) 565 + .opacity(isHovering ? 1 : 0) 566 + .onHover { isHoveringMenu = $0 } 567 + } 568 + .padding(.horizontal, 12) 569 + .padding(.vertical, 8) 570 + .background(isHovering ? Color.secondary.opacity(0.1) : Color.clear) 571 + .cornerRadius(8) 572 + .onHover { isHovering = $0 } 573 + .alert("Error", isPresented: .constant(errorText != nil)) { 574 + Button("OK") { errorText = nil } 575 + } message: { 576 + Text(errorText ?? "") 577 + } 578 + } 579 + 580 + private func formatDuration(_ duration: TimeInterval) -> String { 581 + let minutes = Int(duration) / 60 582 + let seconds = Int(duration) % 60 583 + return String(format: "%d:%02d", minutes, seconds) 584 + } 585 + }