Rockbox open source high quality audio player as a Music Player Daemon
mpris rockbox mpd libadwaita audio rust zig deno
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add playlist support to search

Include saved and smart playlists in search results and UI
Introduce generated SearchPlaylist protobuf type, add lazy-loading
loadPlaylists() and filtering in SearchManager, and update
SearchResultsView and PlaylistsView to display and play playlists

+252 -102
+76 -4
macos/Rockbox/Services/rockbox/v1alpha1/library.pb.swift
··· 619 619 init() {} 620 620 } 621 621 622 + struct Rockbox_V1alpha1_SearchPlaylist: Sendable { 623 + var id: String = String() 624 + var name: String = String() 625 + 626 + var description_p: String { 627 + get { return _description_p ?? String() } 628 + set { _description_p = newValue } 629 + } 630 + var _description_p: String? = nil 631 + var hasDescription_p: Bool { return _description_p != nil } 632 + mutating func clearDescription_p() { _description_p = nil } 633 + 634 + var image: String { 635 + get { return _image ?? String() } 636 + set { _image = newValue } 637 + } 638 + var _image: String? = nil 639 + var hasImage: Bool { return _image != nil } 640 + mutating func clearImage() { _image = nil } 641 + 642 + var isSmart: Bool = false 643 + var trackCount: Int64 = 0 644 + 645 + var unknownFields = SwiftProtobuf.UnknownStorage() 646 + 647 + init() {} 648 + } 649 + 622 650 struct Rockbox_V1alpha1_SearchResponse: Sendable { 623 651 // SwiftProtobuf.Message conformance is added in an extension below. See the 624 652 // `Message` and `Message+*Additions` files in the SwiftProtobuf library for ··· 629 657 var albums: [Rockbox_V1alpha1_Album] = [] 630 658 631 659 var artists: [Rockbox_V1alpha1_Artist] = [] 660 + 661 + var playlists: [Rockbox_V1alpha1_SearchPlaylist] = [] 632 662 633 663 var unknownFields = SwiftProtobuf.UnknownStorage() 634 664 ··· 1732 1762 } 1733 1763 } 1734 1764 1765 + extension Rockbox_V1alpha1_SearchPlaylist: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 1766 + static let protoMessageName: String = _protobuf_package + ".SearchPlaylist" 1767 + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}name\0\u{1}description\0\u{1}image\0\u{2}is_smart\0\u{3}track_count\0") 1768 + 1769 + mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { 1770 + while let fieldNumber = try decoder.nextFieldNumber() { 1771 + switch fieldNumber { 1772 + case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() 1773 + case 2: try { try decoder.decodeSingularStringField(value: &self.name) }() 1774 + case 3: try { try decoder.decodeSingularStringField(value: &self._description_p) }() 1775 + case 4: try { try decoder.decodeSingularStringField(value: &self._image) }() 1776 + case 5: try { try decoder.decodeSingularBoolField(value: &self.isSmart) }() 1777 + case 6: try { try decoder.decodeSingularInt64Field(value: &self.trackCount) }() 1778 + default: break 1779 + } 1780 + } 1781 + } 1782 + 1783 + func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws { 1784 + if !self.id.isEmpty { try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) } 1785 + if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 2) } 1786 + if let v = self._description_p { try visitor.visitSingularStringField(value: v, fieldNumber: 3) } 1787 + if let v = self._image { try visitor.visitSingularStringField(value: v, fieldNumber: 4) } 1788 + if self.isSmart { try visitor.visitSingularBoolField(value: self.isSmart, fieldNumber: 5) } 1789 + if self.trackCount != 0 { try visitor.visitSingularInt64Field(value: self.trackCount, fieldNumber: 6) } 1790 + try unknownFields.traverse(visitor: &visitor) 1791 + } 1792 + 1793 + static func ==(lhs: Rockbox_V1alpha1_SearchPlaylist, rhs: Rockbox_V1alpha1_SearchPlaylist) -> Bool { 1794 + if lhs.id != rhs.id {return false} 1795 + if lhs.name != rhs.name {return false} 1796 + if lhs._description_p != rhs._description_p {return false} 1797 + if lhs._image != rhs._image {return false} 1798 + if lhs.isSmart != rhs.isSmart {return false} 1799 + if lhs.trackCount != rhs.trackCount {return false} 1800 + if lhs.unknownFields != rhs.unknownFields {return false} 1801 + return true 1802 + } 1803 + } 1804 + 1735 1805 extension Rockbox_V1alpha1_SearchResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { 1736 1806 static let protoMessageName: String = _protobuf_package + ".SearchResponse" 1737 - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}tracks\0\u{1}albums\0\u{1}artists\0") 1807 + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}tracks\0\u{1}albums\0\u{1}artists\0\u{1}playlists\0") 1738 1808 1739 1809 mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { 1740 1810 while let fieldNumber = try decoder.nextFieldNumber() { 1741 - // The use of inline closures is to circumvent an issue where the compiler 1742 - // allocates stack space for every case branch when no optimizations are 1743 - // enabled. https://github.com/apple/swift-protobuf/issues/1034 1744 1811 switch fieldNumber { 1745 1812 case 1: try { try decoder.decodeRepeatedMessageField(value: &self.tracks) }() 1746 1813 case 2: try { try decoder.decodeRepeatedMessageField(value: &self.albums) }() 1747 1814 case 3: try { try decoder.decodeRepeatedMessageField(value: &self.artists) }() 1815 + case 4: try { try decoder.decodeRepeatedMessageField(value: &self.playlists) }() 1748 1816 default: break 1749 1817 } 1750 1818 } ··· 1760 1828 if !self.artists.isEmpty { 1761 1829 try visitor.visitRepeatedMessageField(value: self.artists, fieldNumber: 3) 1762 1830 } 1831 + if !self.playlists.isEmpty { 1832 + try visitor.visitRepeatedMessageField(value: self.playlists, fieldNumber: 4) 1833 + } 1763 1834 try unknownFields.traverse(visitor: &visitor) 1764 1835 } 1765 1836 ··· 1767 1838 if lhs.tracks != rhs.tracks {return false} 1768 1839 if lhs.albums != rhs.albums {return false} 1769 1840 if lhs.artists != rhs.artists {return false} 1841 + if lhs.playlists != rhs.playlists {return false} 1770 1842 if lhs.unknownFields != rhs.unknownFields {return false} 1771 1843 return true 1772 1844 }
+60 -16
macos/Rockbox/State/SearchManager.swift
··· 12 12 @Published var searchText: String = "" 13 13 @Published var isSearching: Bool = false 14 14 @Published var searchResults: SearchResults = SearchResults() 15 + @Published var allSavedPlaylists: [SavedPlaylist] = [] 16 + @Published var allSmartPlaylists: [SmartPlaylist] = [] 15 17 16 18 private var searchTask: Task<Void, Never>? 17 - 19 + private var playlistsLoaded = false 20 + 18 21 struct SearchResults { 19 22 var songs: [Song] = [] 20 23 var albums: [Album] = [] 21 24 var artists: [Artist] = [] 22 - 25 + 23 26 var isEmpty: Bool { 24 27 songs.isEmpty && albums.isEmpty && artists.isEmpty 25 28 } 26 - 27 - var totalCount: Int { 28 - songs.count + albums.count + artists.count 29 + } 30 + 31 + var filteredSavedPlaylists: [SavedPlaylist] { 32 + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() 33 + guard !q.isEmpty else { return [] } 34 + return allSavedPlaylists.filter { $0.name.lowercased().contains(q) } 35 + } 36 + 37 + var filteredSmartPlaylists: [SmartPlaylist] { 38 + let q = searchText.trimmingCharacters(in: .whitespaces).lowercased() 39 + guard !q.isEmpty else { return [] } 40 + return allSmartPlaylists.filter { $0.name.lowercased().contains(q) } 41 + } 42 + 43 + var hasPlaylistResults: Bool { 44 + !filteredSavedPlaylists.isEmpty || !filteredSmartPlaylists.isEmpty 45 + } 46 + 47 + func loadPlaylists() async { 48 + guard !playlistsLoaded else { return } 49 + playlistsLoaded = true 50 + async let savedData = fetchSavedPlaylists() 51 + async let smartData = fetchSmartPlaylists() 52 + if let saved = try? await savedData { 53 + allSavedPlaylists = saved.map { 54 + SavedPlaylist( 55 + id: $0.id, name: $0.name, 56 + description: $0.hasDescription_p ? $0.description_p : nil, 57 + image: $0.hasImage ? $0.image : nil, 58 + folderID: $0.hasFolderID ? $0.folderID : nil, 59 + trackCount: $0.trackCount 60 + ) 61 + } 62 + } 63 + if let smart = try? await smartData { 64 + allSmartPlaylists = smart.map { 65 + SmartPlaylist( 66 + id: $0.id, name: $0.name, 67 + description: $0.hasDescription_p ? $0.description_p : nil, 68 + image: $0.hasImage ? $0.image : nil, 69 + isSystem: $0.isSystem 70 + ) 71 + } 29 72 } 30 73 } 31 - 74 + 32 75 func search() { 33 76 searchTask?.cancel() 34 - 77 + 35 78 guard !searchText.trimmingCharacters(in: .whitespaces).isEmpty else { 36 79 searchResults = SearchResults() 37 80 isSearching = false 38 81 return 39 82 } 40 - 83 + 41 84 isSearching = true 42 85 43 86 searchTask = Task { 44 - // Debounce 87 + // Load playlists once in the background (non-blocking for search results) 88 + if !playlistsLoaded { 89 + await loadPlaylists() 90 + } 91 + 92 + // Debounce gRPC search 45 93 try? await Task.sleep(for: .milliseconds(300)) 46 - 47 94 guard !Task.isCancelled else { return } 48 - 95 + 49 96 do { 50 97 let results = try await searchTrack(query: searchText) 51 - 52 98 guard !Task.isCancelled else { return } 53 - 99 + 54 100 searchResults = SearchResults( 55 101 songs: results.tracks.map { track in 56 102 Song( ··· 92 138 ) 93 139 } 94 140 ) 95 - 96 141 } catch { 97 142 if !Task.isCancelled { 98 143 print("Search error: \(error)") ··· 100 145 } 101 146 } 102 147 } 103 - 148 + 104 149 func clear() { 105 150 searchText = "" 106 151 searchResults = SearchResults() ··· 108 153 searchTask?.cancel() 109 154 } 110 155 } 111 -
+29 -29
macos/Rockbox/Views/Playlists/PlaylistsView.swift
··· 21 21 var body: some View { 22 22 ScrollView { 23 23 VStack(alignment: .leading, spacing: 24) { 24 - if !smartPlaylists.isEmpty { 25 - VStack(alignment: .leading, spacing: 12) { 26 - Text("Smart Playlists") 27 - .font(.system(size: 13, weight: .semibold)) 28 - .foregroundStyle(.secondary) 29 - .padding(.horizontal, 20) 30 - 31 - LazyVGrid(columns: columns, spacing: 24) { 32 - ForEach(smartPlaylists) { pl in 33 - SmartPlaylistCardView( 34 - playlist: pl, 35 - onSelect: { navigation.goToSmartPlaylist(pl) }, 36 - onPlay: { 37 - Task { 38 - do { 39 - try await playSmartPlaylist(id: pl.id) 40 - await player.fetchQueue() 41 - } catch { 42 - errorText = String(describing: error) 43 - } 44 - } 45 - } 46 - ) 47 - } 48 - } 49 - .padding(.horizontal, 20) 50 - } 51 - } 52 - 53 24 VStack(alignment: .leading, spacing: 12) { 54 25 HStack { 55 26 Text("Saved Playlists") ··· 96 67 do { 97 68 try await deleteSavedPlaylist(id: pl.id) 98 69 savedPlaylists.removeAll { $0.id == pl.id } 70 + } catch { 71 + errorText = String(describing: error) 72 + } 73 + } 74 + } 75 + ) 76 + } 77 + } 78 + .padding(.horizontal, 20) 79 + } 80 + } 81 + 82 + if !smartPlaylists.isEmpty { 83 + VStack(alignment: .leading, spacing: 12) { 84 + Text("Smart Playlists") 85 + .font(.system(size: 13, weight: .semibold)) 86 + .foregroundStyle(.secondary) 87 + .padding(.horizontal, 20) 88 + 89 + LazyVGrid(columns: columns, spacing: 24) { 90 + ForEach(smartPlaylists) { pl in 91 + SmartPlaylistCardView( 92 + playlist: pl, 93 + onSelect: { navigation.goToSmartPlaylist(pl) }, 94 + onPlay: { 95 + Task { 96 + do { 97 + try await playSmartPlaylist(id: pl.id) 98 + await player.fetchQueue() 99 99 } catch { 100 100 errorText = String(describing: error) 101 101 }
+87 -53
macos/Rockbox/Views/Search/SearchResultsView.swift
··· 12 12 @EnvironmentObject var navigation: NavigationManager 13 13 @EnvironmentObject var player: PlayerState 14 14 @ObservedObject var library: MusicLibrary 15 - @State private var savedPlaylists: [SavedPlaylist] = [] 16 - 15 + 17 16 var body: some View { 18 17 ScrollView { 19 18 VStack(alignment: .leading, spacing: 24) { 20 - if searchManager.searchResults.isEmpty { 19 + 20 + // Empty state 21 + if searchManager.searchResults.isEmpty && !searchManager.hasPlaylistResults { 21 22 emptyResultsView 22 - } else { 23 - // Artists section 24 - if !searchManager.searchResults.artists.isEmpty { 25 - searchSection(title: "Artists") { 26 - ScrollView(.horizontal, showsIndicators: false) { 27 - HStack(spacing: 16) { 28 - ForEach(searchManager.searchResults.artists) { artist in 29 - SearchArtistCard(artist: artist) { 30 - navigation.goToArtist(artist) 31 - searchManager.clear() 32 - } 23 + } 24 + 25 + // Artists 26 + if !searchManager.searchResults.artists.isEmpty { 27 + searchSection(title: "Artists") { 28 + ScrollView(.horizontal, showsIndicators: false) { 29 + HStack(spacing: 16) { 30 + ForEach(searchManager.searchResults.artists) { artist in 31 + SearchArtistCard(artist: artist) { 32 + navigation.goToArtist(artist) 33 + searchManager.clear() 33 34 } 34 35 } 35 - .padding(.horizontal, 20) 36 36 } 37 + .padding(.horizontal, 20) 37 38 } 38 39 } 39 - 40 - // Albums section 41 - if !searchManager.searchResults.albums.isEmpty { 42 - searchSection(title: "Albums") { 43 - ScrollView(.horizontal, showsIndicators: false) { 44 - HStack(spacing: 16) { 45 - ForEach(searchManager.searchResults.albums) { album in 46 - SearchAlbumCard(album: album, savedPlaylists: savedPlaylists) { 47 - navigation.goToAlbum(album) 48 - searchManager.clear() 49 - } 40 + } 41 + 42 + // Albums 43 + if !searchManager.searchResults.albums.isEmpty { 44 + searchSection(title: "Albums") { 45 + ScrollView(.horizontal, showsIndicators: false) { 46 + HStack(spacing: 16) { 47 + ForEach(searchManager.searchResults.albums) { album in 48 + SearchAlbumCard( 49 + album: album, 50 + savedPlaylists: searchManager.allSavedPlaylists 51 + ) { 52 + navigation.goToAlbum(album) 53 + searchManager.clear() 50 54 } 51 55 } 52 - .padding(.horizontal, 20) 53 56 } 57 + .padding(.horizontal, 20) 54 58 } 55 59 } 56 - 57 - // Songs section 58 - if !searchManager.searchResults.songs.isEmpty { 59 - searchSection(title: "Songs") { 60 - LazyVStack(spacing: 0) { 61 - ForEach(Array(searchManager.searchResults.songs.prefix(10).enumerated()), id: \.element.id) { index, song in 62 - SearchSongRow( 63 - song: song, 64 - index: index, 65 - library: library, 66 - savedPlaylists: savedPlaylists 60 + } 61 + 62 + // Songs 63 + if !searchManager.searchResults.songs.isEmpty { 64 + searchSection(title: "Songs") { 65 + LazyVStack(spacing: 0) { 66 + ForEach(Array(searchManager.searchResults.songs.prefix(10).enumerated()), id: \.element.id) { index, song in 67 + SearchSongRow( 68 + song: song, 69 + index: index, 70 + library: library, 71 + savedPlaylists: searchManager.allSavedPlaylists 72 + ) 73 + } 74 + } 75 + .padding(.horizontal, 20) 76 + } 77 + } 78 + 79 + // Playlists 80 + if searchManager.hasPlaylistResults { 81 + searchSection(title: "Playlists") { 82 + ScrollView(.horizontal, showsIndicators: false) { 83 + HStack(spacing: 16) { 84 + ForEach(searchManager.filteredSavedPlaylists) { pl in 85 + PlaylistCardView( 86 + playlist: pl, 87 + onSelect: { 88 + navigation.goToPlaylist(pl) 89 + searchManager.clear() 90 + }, 91 + onPlay: { 92 + Task { 93 + try? await playSavedPlaylist(id: pl.id) 94 + await player.fetchQueue() 95 + } 96 + }, 97 + onDelete: {} 67 98 ) 99 + .frame(width: 130) 100 + } 101 + ForEach(searchManager.filteredSmartPlaylists) { pl in 102 + SmartPlaylistCardView( 103 + playlist: pl, 104 + onSelect: { 105 + navigation.goToSmartPlaylist(pl) 106 + searchManager.clear() 107 + }, 108 + onPlay: { 109 + Task { 110 + try? await playSmartPlaylist(id: pl.id) 111 + await player.fetchQueue() 112 + } 113 + } 114 + ) 115 + .frame(width: 130) 68 116 } 69 117 } 70 118 .padding(.horizontal, 20) 71 119 } 72 120 } 73 121 } 122 + 74 123 } 75 124 .padding(.vertical, 20) 76 - } 77 - .task { 78 - if savedPlaylists.isEmpty { 79 - if let data = try? await fetchSavedPlaylists() { 80 - savedPlaylists = data.map { 81 - SavedPlaylist( 82 - id: $0.id, name: $0.name, 83 - description: $0.hasDescription_p ? $0.description_p : nil, 84 - image: $0.hasImage ? $0.image : nil, 85 - folderID: $0.hasFolderID ? $0.folderID : nil, 86 - trackCount: $0.trackCount 87 - ) 88 - } 89 - } 90 - } 91 125 } 92 126 } 93 127