Add searchable instrument browser
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InstrumentBrowserView: View {
|
||||||
|
private let catalog: LocalInstrumentCatalog
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
init(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) {
|
||||||
|
self.catalog = catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(catalog.instruments(matching: searchText), id: \.id) { instrument in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(instrument.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(instrument.familyCategory)
|
||||||
|
|
||||||
|
if let regionOrigin = instrument.regionOrigin {
|
||||||
|
Text(regionOrigin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.navigationTitle("Instrument Catalog")
|
||||||
|
.searchable(text: $searchText, prompt: "Search instruments")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 460, minHeight: 520)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
InstrumentBrowserView()
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ struct ProjectInspectorView: View {
|
|||||||
@State private var newInstrumentName = ""
|
@State private var newInstrumentName = ""
|
||||||
@State private var newVocalistLabel = ""
|
@State private var newVocalistLabel = ""
|
||||||
@State private var newLanguageName = ""
|
@State private var newLanguageName = ""
|
||||||
|
@State private var isInstrumentBrowserPresented = false
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var saveStatus: SaveStatus?
|
@State private var saveStatus: SaveStatus?
|
||||||
|
|
||||||
@@ -62,6 +63,12 @@ struct ProjectInspectorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Instruments") {
|
Section("Instruments") {
|
||||||
|
Button {
|
||||||
|
isInstrumentBrowserPresented = true
|
||||||
|
} label: {
|
||||||
|
Label("Browse catalog", systemImage: "magnifyingglass")
|
||||||
|
}
|
||||||
|
|
||||||
ForEach($project.instruments, id: \.instrumentId) { $instrument in
|
ForEach($project.instruments, id: \.instrumentId) { $instrument in
|
||||||
Toggle(isOn: $instrument.selected) {
|
Toggle(isOn: $instrument.selected) {
|
||||||
Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId)
|
Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId)
|
||||||
@@ -138,6 +145,9 @@ struct ProjectInspectorView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
.sheet(isPresented: $isInstrumentBrowserPresented) {
|
||||||
|
InstrumentBrowserView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var durationSecondsBinding: Binding<String> {
|
private var durationSecondsBinding: Binding<String> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
public struct LocalInstrumentCatalog: Sendable {
|
public struct LocalInstrumentCatalog: Sendable {
|
||||||
public let items: [InstrumentCatalogItem]
|
public let items: [InstrumentCatalogItem]
|
||||||
|
|
||||||
@@ -5,6 +7,18 @@ public struct LocalInstrumentCatalog: Sendable {
|
|||||||
self.items = items
|
self.items = items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func instruments(matching query: String) -> [InstrumentCatalogItem] {
|
||||||
|
let searchTokens = Self.searchTokens(for: query)
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter { item in
|
||||||
|
searchTokens.isEmpty || searchTokens.allSatisfy { token in
|
||||||
|
Self.searchableTerms(for: item).contains { $0.contains(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
public static let worldwideItems: [InstrumentCatalogItem] =
|
public static let worldwideItems: [InstrumentCatalogItem] =
|
||||||
western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern
|
western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern
|
||||||
|
|
||||||
@@ -281,4 +295,23 @@ public struct LocalInstrumentCatalog: Sendable {
|
|||||||
searchTerms: terms
|
searchTerms: terms
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func searchableTerms(for item: InstrumentCatalogItem) -> [String] {
|
||||||
|
searchTokens(for: [item.name] + item.aliases + item.searchTerms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func searchTokens(for query: String) -> [String] {
|
||||||
|
searchTokens(for: [query])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func searchTokens(for values: [String]) -> [String] {
|
||||||
|
values
|
||||||
|
.joined(separator: " ")
|
||||||
|
.folding(
|
||||||
|
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
|
||||||
|
locale: Locale(identifier: "en_US_POSIX")
|
||||||
|
)
|
||||||
|
.split(whereSeparator: { $0.isWhitespace || $0 == "-" })
|
||||||
|
.map(String.init)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,12 @@ final class LocalInstrumentCatalogTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(items.map(\.id)).count, items.count)
|
XCTAssertEqual(Set(items.map(\.id)).count, items.count)
|
||||||
XCTAssertTrue(items.allSatisfy { !$0.name.isEmpty && !$0.familyCategory.isEmpty })
|
XCTAssertTrue(items.allSatisfy { !$0.name.isEmpty && !$0.familyCategory.isEmpty })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSearchMatchesNamesAliasesAndArabicSearchTerms() {
|
||||||
|
let catalog = LocalInstrumentCatalog()
|
||||||
|
|
||||||
|
XCTAssertEqual(catalog.instruments(matching: "concert flute").map(\.id), ["flute"])
|
||||||
|
XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"])
|
||||||
|
XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
American, traditional, folk, orchestral, electronic and modern
|
American, traditional, folk, orchestral, electronic and modern
|
||||||
instruments.
|
instruments.
|
||||||
- [x] Keep catalog data out of SwiftUI views.
|
- [x] Keep catalog data out of SwiftUI views.
|
||||||
- [ ] Build searchable instrument browser.
|
- [x] Build searchable instrument browser.
|
||||||
- [ ] Add browsing/filtering by family/category.
|
- [ ] Add browsing/filtering by family/category.
|
||||||
- [ ] Add browsing/filtering by region/origin where useful.
|
- [ ] Add browsing/filtering by region/origin where useful.
|
||||||
- [ ] Add checkbox-based multi-select and deselect behavior.
|
- [ ] Add checkbox-based multi-select and deselect behavior.
|
||||||
|
|||||||
Reference in New Issue
Block a user