From f1e00042a944c582b80748daa56124324718fc86 Mon Sep 17 00:00:00 2001 From: Kevin Frei Date: Sat, 13 Jul 2024 14:39:54 -0700 Subject: [PATCH] Jotai song likes and hates (#83) * Album list prefs partly done * Not working, but lots of progress on Likes & Hates support * Added a read only with translation storage helper * Fixed Likes and Hates --- src/Jotai/LikesAndHates.ts | 119 ++++++++++++++++++++++++++++ src/Jotai/Preferences.ts | 17 ++++ src/Jotai/Storage.ts | 104 ++++++++++++++++++++++++- src/Recoil/Likes.ts | 149 ------------------------------------ src/Recoil/Preferences.ts | 17 ---- src/Recoil/ReadOnly.ts | 12 +-- src/Recoil/api.ts | 21 +++-- src/UI/SongMenus.tsx | 22 +++--- src/UI/Views/MixedSongs.tsx | 51 ++++++------ src/UI/Views/Settings.tsx | 39 ++++++---- 10 files changed, 314 insertions(+), 237 deletions(-) create mode 100644 src/Jotai/LikesAndHates.ts create mode 100644 src/Jotai/Preferences.ts delete mode 100644 src/Recoil/Likes.ts delete mode 100644 src/Recoil/Preferences.ts diff --git a/src/Jotai/LikesAndHates.ts b/src/Jotai/LikesAndHates.ts new file mode 100644 index 0000000..3c82eae --- /dev/null +++ b/src/Jotai/LikesAndHates.ts @@ -0,0 +1,119 @@ +import { + AlbumKey, + ArtistKey, + isAlbumKey, + isArtistKey, + SongKey, +} from '@freik/media-core'; +import { isArrayOfString, isBoolean } from '@freik/typechk'; +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import { atomWithMainStorage, atomWithMainStorageTranslation } from './Storage'; + +// const log = MakeLogger('Likes'); +// const err = MakeError('Likes-err'); + +export const neverPlayHatesState = atomWithMainStorage( + 'neverPlayHates', + true, + isBoolean, +); + +export const onlyPlayLikesState = atomWithMainStorage( + 'onlyPlayLikes', + false, + isBoolean, +); + +const songLikeBackerState = atomWithMainStorageTranslation( + 'likedSongs', + new Set(), + isArrayOfString, + (val: Set) => [...val], + (val: string[]) => new Set(val), +); + +const songHateBackerState = atomWithMainStorageTranslation( + 'hatedSongs', + new Set(), + isArrayOfString, + (val: Set) => [...val], + (val: string[]) => new Set(val), +); + +export const isSongLikedFam = atomFamily((key: SongKey) => + atom( + async (get) => (await get(songLikeBackerState)).has(key), + async (get, set, newVal: boolean) => { + const likeSet = await get(songLikeBackerState); + if (likeSet.has(key) === newVal) { + return; + } + const newLikes = new Set(likeSet); + if (newVal) { + newLikes.add(key); + const hateSet = await get(songHateBackerState); + if (hateSet.has(key)) { + const newHates = new Set(hateSet); + newHates.delete(key); + set(songHateBackerState, newHates); + } + } else { + newLikes.delete(key); + } + set(songLikeBackerState, newLikes); + }, + ), +); + +export const isSongHatedFam = atomFamily((key: SongKey) => + atom( + async (get) => (await get(songHateBackerState)).has(key), + async (get, set, newVal: boolean) => { + const hateSet = await get(songHateBackerState); + if (hateSet.has(key) === newVal) { + return; + } + const newHates = new Set(hateSet); + if (newVal) { + newHates.add(key); + const likeSet = await get(songLikeBackerState); + if (likeSet.has(key)) { + const newLikes = new Set(likeSet); + newLikes.delete(key); + set(songLikeBackerState, newLikes); + } + } else { + newHates.delete(key); + } + set(songHateBackerState, newHates); + }, + ), +); + +// JODO: This doesn't handle song lists. Need to get them from the incomplete media interface +// 0 = neutral, 1 == like, 2 = hate, 3 = mixed +export const songListLikeNumberFromStringFam = atomFamily( + (key: AlbumKey | ArtistKey | SongKey) => + atom(async (get) => { + if (isAlbumKey(key) || isArtistKey(key)) { + // JODO: Fix this to get the song list from the key + return 0; + } + const songs = [key]; + let likes = false; + let hates = false; + for (const sk of songs) { + if (!likes) likes = await get(isSongLikedFam(sk)); + if (!hates) hates = await get(isSongHatedFam(sk)); + if (likes && hates) { + break; + } + } + if (likes === hates) { + return likes ? 3 : 0; + } else { + return likes ? 1 : 2; + } + }), +); diff --git a/src/Jotai/Preferences.ts b/src/Jotai/Preferences.ts new file mode 100644 index 0000000..8c8f0c1 --- /dev/null +++ b/src/Jotai/Preferences.ts @@ -0,0 +1,17 @@ +// Only show artists in the list who appear on full albums + +import { isBoolean, isNumber } from '@freik/typechk'; +import { atomWithMainStorage } from './Storage'; + +export const showArtistsWithFullAlbumsState = atomWithMainStorage( + 'FullAlbumsOnly', + false, + isBoolean, +); + +// The minimum # of songs an artist needs to show up in the artist list +export const minSongCountForArtistListState = atomWithMainStorage( + 'MinSongCount', + 1, + isNumber, +); diff --git a/src/Jotai/Storage.ts b/src/Jotai/Storage.ts index 2d52ef4..82765da 100644 --- a/src/Jotai/Storage.ts +++ b/src/Jotai/Storage.ts @@ -38,10 +38,36 @@ function makeGetItem( }; } +function makeGetTranslatedItem( + chk: typecheck, + xlate: (val: U) => T, +): (key: string, initialValue: T) => PromiseLike { + return async (key: string, initialValue: T): Promise => { + const strValue = await Ipc.ReadFromStorage(key); + if (isString(strValue)) { + try { + const val = SafelyUnpickle(strValue, chk); + return isUndefined(val) ? initialValue : xlate(val); + } catch (e) { + /* */ + } + } + return initialValue; + }; +} + async function setItem(key: string, newValue: T): Promise { await Ipc.WriteToStorage(key, Pickle(newValue)); } +async function setTranslatedItem( + key: string, + newValue: T, + xlate: (val: T) => U, +): Promise { + await Ipc.WriteToStorage(key, Pickle(xlate(newValue))); +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars async function noSetItem(_key: string, _newValue: T): Promise { await Promise.resolve(); @@ -55,10 +81,14 @@ async function removeItem(key: string): Promise { async function noRemoveItem(_key: string): Promise { await Promise.resolve(); } + type Unsub = () => void; -function makeSubscribe( - chk: typecheck, -): (key: string, callback: (value: T) => void, initialValue: T) => Unsub { +type Subscriber = ( + key: string, + callback: (value: T) => void, + initVal: T, +) => Unsub; +function makeSubscribe(chk: typecheck): Subscriber { return (key: string, callback: (value: T) => void, initialValue: T) => { const lk = Ipc.Subscribe(key, (val: unknown) => callback(chk(val) ? val : initialValue), @@ -67,6 +97,22 @@ function makeSubscribe( }; } +function makeTranslatedSubscribe( + chk: typecheck, + xlate: (val: U) => T, +): Subscriber { + return (key: string, callback: (value: T) => void, initialValue: T) => { + const lk = Ipc.Subscribe(key, (val: unknown) => { + if (chk(val)) { + callback(xlate(val)); + } else { + callback(initialValue); + } + }); + return () => Ipc.Unsubscribe(lk); + }; +} + export function getMainStorage(chk: typecheck): AsyncStorage { return { getItem: makeGetItem(chk), @@ -100,3 +146,55 @@ export function atomFromMain( ): WritableAtomType { return atomWithStorage(key, init, getMainReadOnlyStorage(chk)); } + +function getTranslatedMainStorage( + chk: typecheck, + fromMain: (val: U) => T, + toMain: (val: T) => U, +): AsyncStorage { + return { + getItem: makeGetTranslatedItem(chk, fromMain), + setItem: async (k, v) => setTranslatedItem(k, toMain(v), fromMain), + removeItem, + subscribe: makeTranslatedSubscribe(chk, fromMain), + }; +} + +function getTranslatedMainReadOnlyStorage( + chk: typecheck, + fromMain: (val: U) => T, +): AsyncStorage { + return { + getItem: makeGetTranslatedItem(chk, fromMain), + setItem: noSetItem, + removeItem: noRemoveItem, + subscribe: makeTranslatedSubscribe(chk, fromMain), + }; +} + +export function atomWithMainStorageTranslation( + key: string, + init: T, + chk: typecheck, + toMain: (val: T) => U, + fromMain: (val: U) => T, +): WritableAtomType { + return atomWithStorage( + key, + init, + getTranslatedMainStorage(chk, fromMain, toMain), + ); +} + +export function atomFromMainStorageTranslation( + key: string, + init: T, + chk: typecheck, + fromMain: (val: U) => T, +): WritableAtomType { + return atomWithStorage( + key, + init, + getTranslatedMainReadOnlyStorage(chk, fromMain), + ); +} diff --git a/src/Recoil/Likes.ts b/src/Recoil/Likes.ts deleted file mode 100644 index 27a9d4b..0000000 --- a/src/Recoil/Likes.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Effects } from '@freik/electron-render'; -import { SongKey } from '@freik/media-core'; -import { isArrayOfString } from '@freik/typechk'; -import { MyTransactionInterface } from '@freik/web-utils'; -import { atom, selectorFamily } from 'recoil'; -import { songListFromKeyFuncFam } from './ReadWrite'; - -// const log = MakeLogger('Likes'); -// const err = MakeError('Likes-err'); - -export const neverPlayHatesState = atom({ - key: 'neverPlayHates', - default: true, - effects: [Effects.syncWithMain()], -}); - -export const onlyPlayLikesState = atom({ - key: 'onlyPlayLikes', - default: false, - effects: [Effects.syncWithMain()], -}); - -const songLikeBackerState = atom>({ - key: 'likedSongs', - default: new Set(), - effects: [ - Effects.bidirectionalSyncWithTranslate>( - (val: Set) => [...val], - (val: unknown) => - isArrayOfString(val) ? new Set(val) : new Set(), - false, - ), - ], -}); - -export function isSongLiked( - { get }: MyTransactionInterface, - key: SongKey, -): boolean { - return get(songLikeBackerState).has(key); -} - -export const songLikeFuncFam = selectorFamily({ - key: 'songLikeState', - get: - (arg: SongKey) => - ({ get }) => { - const likes = get(songLikeBackerState); - return likes.has(arg); - }, - set: - (arg: SongKey) => - ({ get, set }, newValue) => { - const likes = get(songLikeBackerState); - const hates = get(songHateBackerState); - if (newValue !== likes.has(arg)) { - const copy = new Set(likes); - if (newValue) { - copy.add(arg); - if (hates.has(arg)) { - const hcopy = new Set(hates); - hcopy.delete(arg); - set(songHateBackerState, hcopy); - } - } else { - copy.delete(arg); - } - set(songLikeBackerState, copy); - } - }, -}); - -const songHateBackerState = atom>({ - key: 'hatedSongs', - default: new Set(), - effects: [ - Effects.bidirectionalSyncWithTranslate>( - (val: Set) => [...val], - (val: unknown) => - isArrayOfString(val) ? new Set(val) : new Set(), - false, - ), - ], -}); - -export function isSongHated( - { get }: MyTransactionInterface, - key: SongKey, -): boolean { - return get(songHateBackerState).has(key); -} - -export const songHateFuncFam = selectorFamily({ - key: 'songHateState', - get: - (arg: SongKey) => - ({ get }) => { - const hates = get(songHateBackerState); - return hates.has(arg); - }, - set: - (arg: SongKey) => - ({ get, set }, newValue) => { - const hates = get(songHateBackerState); - const likes = get(songLikeBackerState); - if (newValue !== hates.has(arg)) { - const copy = new Set(hates); - if (newValue) { - copy.add(arg); - if (likes.has(arg)) { - const lcopy = new Set(likes); - lcopy.delete(arg); - set(songLikeBackerState, lcopy); - } - } else { - copy.delete(arg); - } - set(songHateBackerState, copy); - } - }, -}); - -// 0 = neutral, 1 == like, 2 = hate, 3 = mixed -export const songLikeNumFromStringFuncFam = selectorFamily({ - key: 'songLikeNumFromKey', - get: - (arg: string) => - ({ get }) => { - const songs = get(songListFromKeyFuncFam(arg)); - if (!songs) { - return 0; - } - let likes = false; - let hates = false; - songs.forEach((songKey) => { - if (get(songLikeFuncFam(songKey))) { - likes = true; - } - if (get(songHateFuncFam(songKey))) { - hates = true; - } - }); - if (likes === hates) { - return likes ? 3 : 0; - } else { - return likes ? 1 : 2; - } - }, -}); diff --git a/src/Recoil/Preferences.ts b/src/Recoil/Preferences.ts deleted file mode 100644 index ef2f899..0000000 --- a/src/Recoil/Preferences.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Effects } from '@freik/electron-render'; -import { atom } from 'recoil'; - -// Only show artists in the list who appear on full albums - -export const showArtistsWithFullAlbumsState = atom({ - key: 'FullAlbumsOnly', - default: false, - effects: [Effects.syncWithMain()], -}); -// The minimum # of songs an artist needs to show up in the artist list - -export const minSongCountForArtistListState = atom({ - key: 'MinSongCount', - default: 1, - effects: [Effects.syncWithMain()], -}); diff --git a/src/Recoil/ReadOnly.ts b/src/Recoil/ReadOnly.ts index ed419e6..42ad2fa 100644 --- a/src/Recoil/ReadOnly.ts +++ b/src/Recoil/ReadOnly.ts @@ -26,10 +26,10 @@ import { emptyLibrary, isFlatAudioDatabase, } from './MusicLibrarySchema'; -import { - minSongCountForArtistListState, - showArtistsWithFullAlbumsState, -} from './Preferences'; +// import { +// minSongCountForArtistListState, +// showArtistsWithFullAlbumsState, +// } from './Preferences'; import { songListState } from './SongPlaying'; const { wrn, log } = MakeLog('EMP:render:ReadOnly:log'); @@ -470,8 +470,8 @@ export const commonDataFuncFam = selectorFamily({ export const filteredArtistsFunc = selector({ key: 'filteredArtists', get: ({ get }) => { - const fullAlbums = get(showArtistsWithFullAlbumsState); - const minSongCount = get(minSongCountForArtistListState); + const fullAlbums = false; // get(showArtistsWithFullAlbumsState); + const minSongCount = 3; // get(minSongCountForArtistListState); const artists = [...get(allArtistsFunc).values()]; if (fullAlbums) { const albums = get(allAlbumsFunc); diff --git a/src/Recoil/api.ts b/src/Recoil/api.ts index bb2c5ec..2e98230 100644 --- a/src/Recoil/api.ts +++ b/src/Recoil/api.ts @@ -19,13 +19,6 @@ import { import { mediaTimeState, playingState } from '../Jotai/MediaPlaying'; import { getStore } from '../Jotai/Storage'; import { isPlaylist, ShuffleArray } from '../Tools'; -import { - isSongHated, - isSongLiked, - neverPlayHatesState, - onlyPlayLikesState, - songHateFuncFam, -} from './Likes'; import { repeatState } from './PlaybackOrder'; import { playlistFuncFam, playlistNamesFunc } from './PlaylistsState'; import { albumByKeyFuncFam, artistByKeyFuncFam } from './ReadOnly'; @@ -33,7 +26,6 @@ import { shuffleFunc } from './ReadWrite'; import { activePlaylistState, currentIndexState, - currentSongKeyFunc, songListState, songPlaybackOrderState, } from './SongPlaying'; @@ -49,6 +41,7 @@ import { * @returns {Promise} true if the next song started playing, * false otherwise */ +// JODO: This is messed up during transition export function MaybePlayNext( xact: MyTransactionInterface, dislike = false, @@ -56,12 +49,14 @@ export function MaybePlayNext( const { get, set } = xact; const curIndex = get(currentIndexState); const songList = get(songListState); - const curSong = get(currentSongKeyFunc); + // const curSong = get(currentSongKeyFunc); if (dislike) { + /* set(songHateFuncFam(curSong), true); if (get(neverPlayHatesState)) { RemoveSongFromNowPlaying(xact, curSong); } + */ } if (curIndex + 1 < songList.length) { set(currentIndexState, curIndex + 1); @@ -105,20 +100,24 @@ export function MaybePlayPrev({ get, set }: MyTransactionInterface): void { * * @returns {SongKey[]} The filtered list of songs */ +// JODO: This is messed up during transition function GetFilteredSongs( xact: MyTransactionInterface, listToFilter: Iterable, ): SongKey[] { + /* const onlyLikes = xact.get(onlyPlayLikesState); const neverHates = xact.get(neverPlayHatesState); + */ const playList = [...listToFilter]; - const filtered = playList.filter((songKey: SongKey) => { + const filtered = playList.filter((/*_songKey: SongKey*/) => { + /* if (onlyLikes) { return isSongLiked(xact, songKey); } if (neverHates) { return !isSongHated(xact, songKey); - } + }*/ return true; }); return filtered.length === 0 ? playList : filtered; diff --git a/src/UI/SongMenus.tsx b/src/UI/SongMenus.tsx index 9012f10..9917824 100644 --- a/src/UI/SongMenus.tsx +++ b/src/UI/SongMenus.tsx @@ -14,12 +14,8 @@ import { MyTransactionInterface, useMyTransaction, } from '@freik/web-utils'; -import { useRecoilValue } from 'recoil'; -import { - songHateFuncFam, - songLikeFuncFam, - songLikeNumFromStringFuncFam, -} from '../Recoil/Likes'; +import { useAtomValue } from 'jotai'; +import { songListLikeNumberFromStringFam } from '../Jotai/LikesAndHates'; import { AddSongs, PlaySongs } from '../Recoil/api'; import { SongListDetailClick } from './DetailPanel/Clickers'; import { ErrorBoundary } from './Utilities'; @@ -85,16 +81,20 @@ export function SongListMenu({ (xact) => () => SongListDetailClick(onGetSongList(xact, context.data), false), ); - const onLove = useMyTransaction((xact) => () => { + // JODO: Update this once we've got the song list from the media interface + const onLove = () => {}; /* + useMyTransaction((xact) => () => { const songs = onGetSongList(xact, context.data); const likeVal = xact.get(songLikeNumFromStringFuncFam(context.data)); for (const song of songs) { // Set it to true if there's any song that *isn't* liked xact.set(songLikeFuncFam(song), likeVal !== 1); } - }); + });*/ - const onHate = useMyTransaction((xact) => () => { + // JODO: Update this once we've got the song list from the media interface + const onHate = () => {}; /* + useJotaiCallback((get, set) => useMyTransaction((xact) => () => { const songs = onGetSongList(xact, context.data); const hateVal = xact.get(songLikeNumFromStringFuncFam(context.data)); for (const song of songs) { @@ -102,11 +102,11 @@ export function SongListMenu({ xact.set(songHateFuncFam(song), hateVal !== 2); } }); - + */ const onShow = () => { Ipc.InvokeMain(IpcId.ShowLocFromKey, context.data).catch(Catch); }; - const likeNum = useRecoilValue(songLikeNumFromStringFuncFam(context.data)); + const likeNum = useAtomValue(songListLikeNumberFromStringFam(context.data)); const likeIcons = ['Like', 'LikeSolid', 'Like', 'More']; const hateIcons = ['Dislike', 'Dislike', 'DislikeSolid', 'More']; if (context.data === '' || context.spot === undefined) { diff --git a/src/UI/Views/MixedSongs.tsx b/src/UI/Views/MixedSongs.tsx index fef8502..0cb299f 100644 --- a/src/UI/Views/MixedSongs.tsx +++ b/src/UI/Views/MixedSongs.tsx @@ -8,14 +8,16 @@ import { import { Song, SongKey } from '@freik/media-core'; import { isNumber } from '@freik/typechk'; import { useMyTransaction } from '@freik/web-utils'; +import { useAtomValue } from 'jotai'; import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'; +import { useJotaiCallback } from '../../Jotai/Helpers'; +import { + isSongHatedFam, + isSongLikedFam, + songListLikeNumberFromStringFam, +} from '../../Jotai/LikesAndHates'; import { ignoreArticlesState } from '../../Jotai/SimpleSettings'; import { getStore } from '../../Jotai/Storage'; -import { - songHateFuncFam, - songLikeFuncFam, - songLikeNumFromStringFuncFam, -} from '../../Recoil/Likes'; import { allAlbumsFunc, allArtistsFunc, @@ -60,25 +62,28 @@ const songContextState = atom({ }); function Liker({ songId }: { songId: SongKey }): JSX.Element { - const likeNum = useRecoilValue(songLikeNumFromStringFuncFam(songId)); + const likeNum = useAtomValue(songListLikeNumberFromStringFam(songId)); const strings = ['⋯', '👍', '👎', '⋮']; - const onClick = useMyTransaction(({ set }) => () => { - switch (likeNum) { - case 0: // neutral - set(songLikeFuncFam(songId), true); - break; - case 1: // like - set(songHateFuncFam(songId), true); - break; - case 2: // hate - set(songHateFuncFam(songId), false); - break; - case 3: // mixed - set(songLikeFuncFam(songId), false); - set(songHateFuncFam(songId), false); - break; - } - }); + const onClick = useJotaiCallback( + (get, set) => { + switch (likeNum) { + case 0: // neutral + set(isSongLikedFam(songId), true); + break; + case 1: // like + set(isSongHatedFam(songId), true); + break; + case 2: // hate + set(isSongHatedFam(songId), false); + break; + case 3: // mixed + set(isSongLikedFam(songId), false); + set(isSongHatedFam(songId), false); + break; + } + }, + [songId, likeNum], + ); return
{strings[likeNum]}
; } diff --git a/src/UI/Views/Settings.tsx b/src/UI/Views/Settings.tsx index c2e085f..3ad44cc 100644 --- a/src/UI/Views/Settings.tsx +++ b/src/UI/Views/Settings.tsx @@ -18,16 +18,24 @@ import { MyTransactionInterface, Spinner, StateToggle, - useBoolRecoilState, useMyTransaction, } from '@freik/web-utils'; import { useAtom, useAtomValue } from 'jotai'; import React, { useState } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { AddIgnoreItem, RemoveIgnoreItem } from '../../ipc'; +import { AsyncHandler } from '../../Jotai/Helpers'; import { useBoolAtom } from '../../Jotai/Hooks'; +import { + neverPlayHatesState, + onlyPlayLikesState, +} from '../../Jotai/LikesAndHates'; import { rescanInProgressState } from '../../Jotai/Miscellany'; +import { + minSongCountForArtistListState, + showArtistsWithFullAlbumsState, +} from '../../Jotai/Preferences'; import { albumCoverNameState, defaultLocationState, @@ -37,11 +45,6 @@ import { locationsState, saveAlbumArtworkWithMusicState, } from '../../Jotai/SimpleSettings'; -import { neverPlayHatesState, onlyPlayLikesState } from '../../Recoil/Likes'; -import { - minSongCountForArtistListState, - showArtistsWithFullAlbumsState, -} from '../../Recoil/Preferences'; import { allAlbumsFunc, allArtistsFunc, @@ -234,22 +237,24 @@ function ArticleSorting(): JSX.Element { } function ArtistFiltering(): JSX.Element { - const onlyAlbumArtists = useBoolRecoilState(showArtistsWithFullAlbumsState); - const [songCount, setSongCount] = useRecoilState( - minSongCountForArtistListState, - ); + const onlyAlbumArtists = useBoolAtom(showArtistsWithFullAlbumsState); + const [songCount, setSongCount] = useAtom(minSongCountForArtistListState); return ( <> setSongCount(Math.min(100, songCount + 1))} - onDecrement={() => setSongCount(Math.max(1, songCount - 1))} + onIncrement={AsyncHandler(() => + setSongCount(Math.min(100, songCount + 1)), + )} + onDecrement={AsyncHandler(() => + setSongCount(Math.max(1, songCount - 1)), + )} style={{ width: '10px' }} /> @@ -257,8 +262,8 @@ function ArtistFiltering(): JSX.Element { } function LikeFiltering(): JSX.Element { - const neverPlayHates = useBoolRecoilState(neverPlayHatesState); - const onlyPlayLikes = useBoolRecoilState(onlyPlayLikesState); + const neverPlayHates = useBoolAtom(neverPlayHatesState); + const onlyPlayLikes = useBoolAtom(onlyPlayLikesState); return ( <>