Fix search: allow spaces and match multi-word queries as separate words

Space was hard-coded as the global play/pause toggle, so it never reached
the search-typing logic - typing "geolino azte" would toggle playback
instead of adding the space. Now a space is appended to an in-progress
search instead.

Search also required the whole query to be one contiguous substring of
title + artist, which misses queries like "conni rad" for "Conni lernt
Rad fahren" or "geolino azte" for a podcast episode titled "Azteken" by
"GEOlino Spezial" (title comes before artist). Each word is now matched
independently, in any order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 12:11:36 +02:00
parent a3b2c0ce2f
commit 829ea89386
3 changed files with 44 additions and 7 deletions

View File

@@ -76,6 +76,12 @@ describe("search", () => {
it("matches title and artist together", () => {
expect(resultsFor({ ...initialUiState, search: "rolf" }).albums.map((a) => a.id)).toEqual(["c"]);
});
it("matches multi-word queries as separate words, not one contiguous phrase", () => {
// "conni horbuch" is the reverse of "Hörbuch Conni" - a contiguous-substring search
// would miss it, but each word does show up somewhere in title + artist.
expect(resultsFor({ ...initialUiState, search: "conni horbuch" }).albums.map((a) => a.id)).toEqual(["b"]);
});
});
describe("keyboard", () => {
@@ -257,6 +263,17 @@ describe("keyboard", () => {
expect(press(" ", inRoom)).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
});
it("types a space into an in-progress search instead of toggling playback", () => {
const searching: UiState = { ...initialUiState, search: "geolino" };
expect(press(" ", searching)).toEqual([
{ type: "ui", patch: { search: "geolino ", selIndex: 0 } },
]);
});
it("still toggles playback on space when there is no search yet", () => {
expect(press(" ")).toEqual([{ type: "pop", freq: 340 }, { type: "toggle" }]);
});
it("does not swallow backspace when there is nothing to delete", () => {
expect(press("Backspace")).toEqual([]);
expect(press("Backspace", { ...initialUiState, search: "ab" })).toEqual([

View File

@@ -208,8 +208,12 @@ export function handleKey(
if (media) return media;
}
// Space is the one other transport key reachable from anywhere, same as Shift+media.
// Space is the one other transport key reachable from anywhere, same as Shift+media -
// except mid-search, where a space is punctuation the query needs (e.g. "geolino azte").
if (key === " ") {
if (state.page === "music" && state.view === "browse" && state.openAlbumId === null && !state.showHelp && state.search) {
return [{ type: "ui", patch: { search: state.search + " ", selIndex: 0 } }];
}
return [{ type: "pop", freq: 340 }, { type: "toggle" }];
}

View File

@@ -30,6 +30,22 @@ export function normalize(value: string): string {
.replace(/[^a-z0-9]/g, "");
}
/** Words of a query, normalized individually. A space-separated query like "conni rad"
* should find "Conni lernt Rad fahren" even though "rad" isn't right after "conni" -
* requiring the whole phrase to be one contiguous substring is too strict for that. */
function searchWords(value: string): string[] {
return (value ?? "")
.trim()
.split(/\s+/)
.map(normalize)
.filter(Boolean);
}
/** Every word has to show up somewhere in the haystack, in any order. */
function matchesWords(haystack: string, words: string[]): boolean {
return words.every((word) => haystack.includes(word));
}
/** Which of the three shelves an album belongs on. A podcast is exactly a
* `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
* album joins whichever of music/audiobooks matches what it actually holds. */
@@ -81,12 +97,12 @@ export function categoryMatches(query: BrowseQuery): Category[] {
export function albumMatches(query: BrowseQuery): Album[] {
if (query.mode === "tracks") return [];
const needle = normalize(query.search);
const words = searchWords(query.search);
let candidates = pool(query.albums, query.group);
if (!needle && !query.category) return [];
if (!words.length && !query.category) return [];
if (query.category) candidates = candidates.filter((a) => a.category === query.category);
if (!needle) return candidates;
return candidates.filter((a) => normalize(a.title + a.artist).includes(needle));
if (!words.length) return candidates;
return candidates.filter((a) => matchesWords(normalize(a.title + a.artist), words));
}
/** Capped, because an empty query over 900 podcast episodes is not a useful screen. */
@@ -94,11 +110,11 @@ export const MAX_SONG_HITS = 40;
export function songMatches(query: BrowseQuery): SongHit[] {
if (query.mode !== "tracks") return [];
const needle = normalize(query.search);
const words = searchWords(query.search);
const hits: SongHit[] = [];
for (const album of pool(query.albums, query.group)) {
album.tracks.forEach((track, index) => {
if (!needle || normalize(track.title).includes(needle)) {
if (!words.length || matchesWords(normalize(track.title), words)) {
hits.push({ album, index, title: track.title, duration: track.duration });
}
});