diff --git a/js/components/MediaLibraryWindow/TracksTable.tsx b/js/components/MediaLibraryWindow/TracksTable.tsx index c7e3003a..9732adca 100644 --- a/js/components/MediaLibraryWindow/TracksTable.tsx +++ b/js/components/MediaLibraryWindow/TracksTable.tsx @@ -8,9 +8,18 @@ import { AppState, PlaylistTrack } from "../../types"; interface StateProps { tracks: PlaylistTrack[]; + filterTracks: (query: string) => PlaylistTrack[]; } -class TracksTable extends React.Component { +interface State { + filter: string; +} + +class TracksTable extends React.Component { + constructor(props: StateProps) { + super(props); + this.state = { filter: "" }; + } render() { return (
{ style={{ marginLeft: 12, flexGrow: 1 }} type="text" className="webamp-media-library-item" + onChange={e => this.setState({ filter: e.target.value })} />
{ - {this.props.tracks.map(track => { + {this.props.filterTracks(this.state.filter).map(track => { return ( {track.artist} @@ -86,7 +96,8 @@ class TracksTable extends React.Component { const mapStateToProps = (state: AppState): StateProps => { return { - tracks: Object.values(Selectors.getTracks(state)) + tracks: Object.values(Selectors.getTracks(state)), + filterTracks: Selectors.getTracksMatchingFilter(state) }; }; diff --git a/js/selectors.ts b/js/selectors.ts index a752fd62..01bcd38c 100644 --- a/js/selectors.ts +++ b/js/selectors.ts @@ -25,6 +25,7 @@ import * as fromDisplay from "./reducers/display"; import * as fromEqualizer from "./reducers/equalizer"; import * as fromMedia from "./reducers/media"; import * as fromWindows from "./reducers/windows"; +import * as TrackUtils from "./trackUtils"; import { generateGraph } from "./resizeUtils"; import { SerializedStateV1 } from "./serializedStates/v1Types"; @@ -47,6 +48,16 @@ export const getEqfData = createSelector(getSliders, sliders => { export const getTracks = (state: AppState) => state.tracks; +export const getTracksMatchingFilter = createSelector(getTracks, tracks => { + const tracksArray = Object.values(tracks); + const filter = Utils.makeCachingFilterFunction(tracksArray, (track, query) => + TrackUtils.trackFilterContents(track).includes(query) + ); + return (filterString: string): PlaylistTrack[] => { + return filter(filterString.toLowerCase()); + }; +}); + export const getTrackUrl = (state: AppState) => { return (id: number): string | null => { const track = state.tracks[id]; diff --git a/js/trackUtils.ts b/js/trackUtils.ts new file mode 100644 index 00000000..2f41a831 --- /dev/null +++ b/js/trackUtils.ts @@ -0,0 +1,46 @@ +import { PlaylistTrack } from "./types"; +import * as Utils from "./utils"; +import * as FileUtils from "./fileUtils"; + +export const trackName = Utils.weakMapMemoize( + (track: PlaylistTrack): string => { + const { artist, title, defaultName, url } = track; + if (artist && title) { + return `${artist} - ${title}`; + } else if (title) { + return title; + } else if (defaultName) { + return defaultName; + } else if (url) { + const filename = FileUtils.filenameFromUrl(url); + if (filename) { + return filename; + } + } + return "???"; + } +); + +export const trackFilename = Utils.weakMapMemoize( + (track: PlaylistTrack): string => { + if (track.url) { + const urlFilename = FileUtils.filenameFromUrl(track.url); + if (urlFilename != null) { + return urlFilename; + } + } + if (track.defaultName) { + return track.defaultName; + } + return "???"; + } +); + +export const trackFilterContents = Utils.weakMapMemoize( + (track: PlaylistTrack): string => { + return [track.artist, track.title, track.defaultName] + .filter(Boolean) + .join("|") + .toLowerCase(); + } +); diff --git a/js/utils.test.ts b/js/utils.test.ts index f451c11c..71e8f1f5 100644 --- a/js/utils.test.ts +++ b/js/utils.test.ts @@ -11,7 +11,8 @@ import { segment, moveSelected, spliceIn, - getFileExtension + getFileExtension, + makeCachingFilterFunction } from "./utils"; const fixture = (filename: string) => @@ -273,3 +274,89 @@ describe("spliceIn", () => { expect(spliced).toEqual([1, 200, 2, 3]); }); }); + +describe("makeCachingFilterFunction", () => { + test("caches exact queries", () => { + const values = ["abc", "b", "c"]; + const includes = jest.fn((v, query) => v.includes(query)); + const filter = makeCachingFilterFunction(values, includes); + expect(filter("c")).toEqual(["abc", "c"]); + expect(includes.mock.calls.length).toBe(3); + expect(filter("c")).toEqual(["abc", "c"]); + expect(includes.mock.calls.length).toBe(3); + }); + + test("caches sub queries", () => { + const values = ["a--", "ab-", "abc"]; + const includes = jest.fn((v, query) => v.includes(query)); + let comparisons = 0; + const newComparisons = () => { + const recent = includes.mock.calls.length - comparisons; + comparisons += recent; + return recent; + }; + const filter = makeCachingFilterFunction(values, includes); + // Intial search + expect(filter("ab")).toEqual(["ab-", "abc"]); + expect(newComparisons()).toBe(3); // Looks at all elements + + // Second search where original search is a prefix + expect(filter("abc")).toEqual(["abc"]); + expect(newComparisons()).toBe(2); // Only reconsiders the previous matches + + // Unique search + expect(filter("b")).toEqual(["ab-", "abc"]); // Looks at all elements + expect(newComparisons()).toBe(3); // Reconsiders all elements + + expect(filter("bc")).toEqual(["abc"]); // Only reconsidres the matches that already include `b` + expect(newComparisons()).toBe(2); + + // Go back to the initial serach + expect(filter("ab")).toEqual(["ab-", "abc"]); + expect(newComparisons()).toBe(0); // Result is cached + + // A variation on the second search + expect(filter("abcd")).toEqual([]); + expect(newComparisons()).toBe(1); // Only recondsiders the results of `abc` + }); + + test("big data", () => { + const values = [...Array(10000)].map((val, i) => String(i)); + const includes = jest.fn((v, query) => v.includes(query)); + let comparisons = 0; + const newComparisons = () => { + const recent = includes.mock.calls.length - comparisons; + comparisons += recent; + return recent; + }; + const filter = makeCachingFilterFunction(values, includes); + // Intial search + expect(filter("").length).toEqual(10000); + expect(newComparisons()).toBe(0); // Looks at zero + + expect(filter("1").length).toEqual(3439); + expect(newComparisons()).toBe(10000); // Looks at all elements + + expect(filter("12").length).toEqual(299); + expect(newComparisons()).toBe(3439); + + expect(filter("123").length).toEqual(20); + expect(newComparisons()).toBe(299); + + expect(filter("1234").length).toEqual(1); + expect(newComparisons()).toBe(20); + + expect(filter("12345").length).toEqual(0); + expect(newComparisons()).toBe(1); + + // A variation on the initial non-empty query + expect(filter("11").length).toEqual(280); + expect(newComparisons()).toBe(3439); + + expect(filter("111").length).toEqual(19); + expect(newComparisons()).toBe(280); + + expect(filter("1111").length).toEqual(1); + expect(newComparisons()).toBe(19); + }); +}); diff --git a/js/utils.ts b/js/utils.ts index f72fa7af..7fb21842 100644 --- a/js/utils.ts +++ b/js/utils.ts @@ -367,3 +367,51 @@ export function getWindowSize(): { width: number; height: number } { ) }; } + +export function weakMapMemoize( + func: (value: T) => R +): (value: T) => R { + const cache = new WeakMap(); + return (value: T) => { + if (!cache.has(value)) { + cache.set(value, func(value)); + } + return cache.get(value); + }; +} + +interface Cache { + results?: V[]; + subCaches: { [char: string]: Cache }; +} + +// Is this a premature optimizaiton? Probably. But it's my side-project so I can +// do what I like. :P +export function makeCachingFilterFunction( + values: V[], + includes: (v: V, query: string) => boolean +) { + const cache: Cache = { + results: values, + subCaches: {} + }; + return (query: string): V[] => { + let queryCache: Cache = cache; + let lastResults: V[] = values; + for (const char of query) { + let letterCaches = queryCache.subCaches[char]; + if (!letterCaches) { + letterCaches = queryCache.subCaches[char] = { subCaches: {} }; + } else if (letterCaches.results) { + lastResults = letterCaches.results; + } + queryCache = letterCaches; + } + + if (!queryCache.results) { + queryCache.results = lastResults.filter(v => includes(v, query)); + } + + return queryCache.results; + }; +}