diff --git a/android/app/src/main/java/app/hocket/AppPrefs.kt b/android/app/src/main/java/app/hocket/AppPrefs.kt index 9b1c493..4d23e77 100644 --- a/android/app/src/main/java/app/hocket/AppPrefs.kt +++ b/android/app/src/main/java/app/hocket/AppPrefs.kt @@ -1,6 +1,10 @@ package app.hocket import android.content.Context +import app.hocket.core.Commands +import app.hocket.core.HocketJson +import app.hocket.core.SettingKeys +import app.hocket.core.client.CoreClient import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -8,7 +12,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer private val Context.dataStore: DataStore by preferencesDataStore(name = "hocket-app") @@ -18,6 +27,35 @@ interface NavBarPrefs { suspend fun setNavItems(ids: List) } +/** + * The bar as the synced core setting `nav.mobileBar`, so every phone on the account shares it. It + * is the phone's own list: the desktop sidebar keeps its order in `actions.order.sidebar`. + * Nothing is emitted until the core's settings snapshot has the key. + */ +class SyncedNavBarPrefs(private val client: CoreClient) : NavBarPrefs { + private val ids = ListSerializer(String.serializer()) + + override val navItems: Flow> = client.settings + .mapNotNull { it[SettingKeys.NAV_MOBILE_BAR]?.value } + .map { raw -> runCatching { HocketJson.json.decodeFromString(ids, raw) }.getOrDefault(emptyList()) } + .distinctUntilChanged() + + override suspend fun setNavItems(ids: List) { + client.dispatch(Commands.setSetting(SettingKeys.NAV_MOBILE_BAR, HocketJson.json.encodeToString(this.ids, ids))) + } + + /** + * One-time move of a bar saved before it synced ([AppPrefs]' device-local copy): taken only + * when the account has none yet, and the local copy is cleared either way. + */ + suspend fun adoptLocal(local: AppPrefs) { + val old = local.navItems.first() + if (old.isEmpty()) return + if (navItems.first().isEmpty()) setNavItems(old) + local.setNavItems(emptyList()) + } +} + /** * App-only preferences (DataStore). Anything that is not a core registry key lives here: the core * refuses unknown settings keys, and these are presentation choices of this app alone. @@ -27,7 +65,7 @@ class AppPrefs(private val store: DataStore) : NavBarPrefs { private val navItemsKey = stringPreferencesKey("navItems") - /** Ordered navigation item ids, comma-separated; empty = default. */ + /** Ordered navigation item ids, comma-separated; empty = default. Superseded by [SyncedNavBarPrefs], which adopts it once. */ override val navItems: Flow> = store.data.map { p -> p[navItemsKey]?.split(',')?.filter { it.isNotBlank() } ?: emptyList() } override suspend fun setNavItems(ids: List) { diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index 1ec3b9d..bd2451a 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -153,7 +153,7 @@ fun AppRoot( val fatal by fatalError.collectAsStateWithLifecycle() fatal?.let { FatalErrorScreen(it); return } if (client == null) { LoadingScreen(); return } - CompositionLocalProvider(LocalCoreClient provides client, LocalNavBarPrefs provides rememberNavBarPrefs()) { + CompositionLocalProvider(LocalCoreClient provides client, LocalNavBarPrefs provides rememberShellNavBarPrefs(client)) { val started by client.started.collectAsStateWithLifecycle() val servers by client.servers.collectAsStateWithLifecycle() // The fake core has nothing to replay; the native core waits for the keystore replay first. diff --git a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt index 479bad0..5b0f912 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt @@ -84,8 +84,8 @@ import app.hocket.HocketApp import app.hocket.InMemoryNavBarPrefs import app.hocket.NavBarPrefs import app.hocket.R -import app.hocket.core.Commands -import app.hocket.ui.LocalCoreClient +import app.hocket.SyncedNavBarPrefs +import app.hocket.core.client.CoreClient import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -100,13 +100,25 @@ import sh.calvin.reorderable.ReorderableColumn /** The bar's storage for this composition; AppRoot provides one so every reader shares it. */ val LocalNavBarPrefs = staticCompositionLocalOf { null } -/** The app's DataStore-backed prefs when running in the app, else an in-memory store. */ +/** The provided store (AppRoot's), else an in-memory one. */ @Composable fun rememberNavBarPrefs(): NavBarPrefs { val provided = LocalNavBarPrefs.current - val app = LocalContext.current.applicationContext as? HocketApp val fallback = remember { InMemoryNavBarPrefs() } - return provided ?: app?.prefs ?: fallback + return provided ?: fallback +} + +/** + * The bar's store for the shell: a host-provided one (tests), else in the app the synced core + * setting, adopting the device-local bar saved before it synced; elsewhere in memory. + */ +@Composable +fun rememberShellNavBarPrefs(client: CoreClient): NavBarPrefs { + val provided = LocalNavBarPrefs.current + val app = LocalContext.current.applicationContext as? HocketApp + val prefs = remember(provided, app, client) { provided ?: if (app != null) SyncedNavBarPrefs(client) else InMemoryNavBarPrefs() } + if (prefs is SyncedNavBarPrefs && app != null) LaunchedEffect(prefs) { prefs.adoptLocal(app.prefs) } + return prefs } /** The user's bar, migrated and bounded ([NavItem.fromIds]). */ @@ -287,16 +299,14 @@ fun NavItem.label(): String = stringResource( @Composable fun NavItem.barLabel(): String = if (this == NavItem.RecentQueues) stringResource(R.string.nav_recent_queues_short) else label() -/** Saves the bar and keeps the core's `sidebar` surface in step (as the navigation items always have). */ +/** Saves the bar (device-local; the desktop sidebar order is separate). */ @Composable fun rememberBarSaver(prefs: NavBarPrefs = rememberNavBarPrefs()): (List) -> Unit { - val client = LocalCoreClient.current val scope = rememberCoroutineScope() - return remember(prefs, client, scope) { + return remember(prefs, scope) { { next -> val ids = if (next == NavItem.DEFAULT) emptyList() else next.map { it.id } scope.launch { prefs.setNavItems(ids) } - client.dispatch(Commands.setActionOrder("sidebar", next.map { it.canonicalActionId }.distinct())) } } } diff --git a/android/app/src/main/java/app/hocket/ui/nav/Routes.kt b/android/app/src/main/java/app/hocket/ui/nav/Routes.kt index 72021a9..9353d48 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/Routes.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/Routes.kt @@ -42,22 +42,22 @@ sealed interface Route { /** * The places the phone's bottom bar can hold, chosen and ordered by the user (a device-local app * preference, [app.hocket.AppPrefs]). Settings is never one: it lives behind the account button in - * the main destinations' top app bars. [canonicalActionId] is the registry action for the place, - * used to keep the core's `sidebar` surface in step. + * the main destinations' top app bars. The bar is the phone's own: it does not touch the synced + * desktop sidebar order (the core's `sidebar` surface). */ -enum class NavItem(val id: String, val canonicalActionId: String) { - Home("home", "navigateHome"), - Search("search", "findInList"), - Library("library", "navigateLibrary"), - Playlists("playlists", "navigatePlaylists"), - Artists("artists", "navigateArtists"), - Albums("albums", "navigateAlbums"), - Songs("songs", "navigateTracks"), - Genres("genres", "navigateGenres"), - RecentQueues("recentQueues", "navigateRecent"), - Stats("stats", "navigateStats"), - Downloads("downloads", "navigateDownloads"), - Filters("filters", "navigateFilters"); +enum class NavItem(val id: String) { + Home("home"), + Search("search"), + Library("library"), + Playlists("playlists"), + Artists("artists"), + Albums("albums"), + Songs("songs"), + Genres("genres"), + RecentQueues("recentQueues"), + Stats("stats"), + Downloads("downloads"), + Filters("filters"); companion object { val DEFAULT = listOf(Home, Search, Library) diff --git a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt index 0ba1f8f..f9bd4c7 100644 --- a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt @@ -20,11 +20,11 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.stringPreferencesKey import androidx.test.ext.junit.runners.AndroidJUnit4 import app.hocket.AppPrefs import app.hocket.InMemoryNavBarPrefs import app.hocket.NavBarPrefs +import app.hocket.SyncedNavBarPrefs import app.hocket.ui.a11y.A11yChecks import app.hocket.ui.a11y.performCustomAction import app.hocket.ui.nav.AppRoot @@ -34,7 +34,6 @@ import app.hocket.core.SettingKeys import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -111,22 +110,16 @@ class NavBarCustomisationTest { assertEquals(NavItem.DEFAULT, NavItem.fromIds(listOf("nonsense", "settings"))) assertEquals("duplicates collapse", listOf(NavItem.Home, NavItem.Search), NavItem.fromIds(listOf("home", "home", "search"))) assertEquals("capped at five", 5, NavItem.fromIds(listOf("home", "search", "library", "albums", "songs", "genres")).size) - // Every place has its own registry action; Library no longer borrows navigateAlbums. - assertEquals("navigateLibrary", NavItem.Library.canonicalActionId) - assertEquals(NavItem.entries.size, NavItem.entries.map { it.canonicalActionId }.distinct().size) // And in the app: an old stored order with Settings in it. start(InMemoryNavBarPrefs(listOf("downloads", "settings", "home", "filters"))) compose.waitUntil(5_000) { bar() == listOf("downloads", "home", "filters") } } @Test - fun addReorderAndRemovePersistAcrossRecreation() = runBlocking { - val file = tmp.newFile("bar.preferences_pb").also { it.delete() } - val scope1 = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val store1 = PreferenceDataStoreFactory.create(scope = scope1) { file } - var prefs by mutableStateOf(AppPrefs(store1)) - var generation by mutableIntStateOf(0) + fun addReorderAndRemovePersistAcrossRecreation() { val core = TestCore(startPlaying = false) + var prefs by mutableStateOf(SyncedNavBarPrefs(core.client)) + var generation by mutableIntStateOf(0) compose.setThemedContent(core) { key(generation) { CompositionLocalProvider(LocalNavBarPrefs provides prefs) { AppRoot(core.client) } } } @@ -141,21 +134,34 @@ class NavBarCustomisationTest { compose.waitUntil(5_000) { bar() == listOf("home", "search", "albums", "library") } click("bottomBar.remove.search") compose.waitUntil(5_000) { bar() == listOf("home", "albums", "library") } - val key = stringPreferencesKey("navItems") - compose.waitUntil(5_000) { runBlocking { store1.data.first()[key] } == "home,albums,library" } - // The core's sidebar surface follows, Library as its own action (not a second Albums). - compose.waitUntil(5_000) { - core.client.settings.value[SettingKeys.ACTIONS_ORDER_SIDEBAR]?.value == """["navigateHome","navigateAlbums","navigateLibrary"]""" - } + // Saved as the synced phone bar; the desktop sidebar's order is left alone. + compose.waitUntil(5_000) { core.client.settings.value[SettingKeys.NAV_MOBILE_BAR]?.value == """["home","albums","library"]""" } + assertEquals("[]", core.client.settings.value[SettingKeys.ACTIONS_ORDER_SIDEBAR]?.value) - // Recreate: a fresh DataStore over the same file, a fresh composition. - scope1.coroutineContext[kotlinx.coroutines.Job]!!.cancelAndJoin() - val store2 = PreferenceDataStoreFactory.create(scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) { file } - compose.runOnUiThread { prefs = AppPrefs(store2); generation++ } + // Recreate: a fresh store over the same core (another phone on the account), a fresh composition. + compose.runOnUiThread { prefs = SyncedNavBarPrefs(core.client); generation++ } await("navBar") compose.waitUntil(5_000) { bar() == listOf("home", "albums", "library") } } + @Test + fun aDeviceLocalBarIsAdoptedOnce() = runBlocking { + val file = tmp.newFile("bar.preferences_pb").also { it.delete() } + val local = AppPrefs(PreferenceDataStoreFactory.create(scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) { file }) + local.setNavItems(listOf("downloads", "home", "filters")) + val core = TestCore(startPlaying = false) + core.start() + val synced = SyncedNavBarPrefs(core.client) + synced.adoptLocal(local) + assertEquals(listOf("downloads", "home", "filters"), synced.navItems.first { it.isNotEmpty() }) + assertEquals(emptyList(), local.navItems.first()) + // An account that already has a bar keeps it. + local.setNavItems(listOf("home", "stats")) + synced.adoptLocal(local) + assertEquals(listOf("downloads", "home", "filters"), synced.navItems.first()) + assertEquals(emptyList(), local.navItems.first()) + } + @Test fun theBarHoldsTwoToFiveItemsAndSaysWhy() { start(InMemoryNavBarPrefs(listOf("home", "search", "library", "albums", "songs"))) diff --git a/android/core/src/main/java/app/hocket/core/SettingKeys.kt b/android/core/src/main/java/app/hocket/core/SettingKeys.kt index a8f7cd4..fbdc994 100644 --- a/android/core/src/main/java/app/hocket/core/SettingKeys.kt +++ b/android/core/src/main/java/app/hocket/core/SettingKeys.kt @@ -32,6 +32,7 @@ object SettingKeys { const val ACTIONS_ORDER_CONTEXT_MENU = "actions.order.contextMenu" const val ACTIONS_ORDER_SIDEBAR = "actions.order.sidebar" const val ACTIONS_ORDER_MEDIA_SESSION = "actions.order.mediaSession" + const val NAV_MOBILE_BAR = "nav.mobileBar" const val SYNC_ENABLED = "sync.enabled" const val CONNECT_COORDINATOR_URL = "connect.coordinatorUrl" const val CONNECT_LAN_DISCOVERY = "connect.lanDiscovery" diff --git a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt index 6222a8b..d31fc6c 100644 --- a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt +++ b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt @@ -149,6 +149,7 @@ class FakeCore( def(SettingKeys.DISPLAY_DYNAMIC_COLOUR, "true", SettingScope.DeviceLocal) def(SettingKeys.ACTIONS_ORDER_CONTEXT_MENU, "[]", SettingScope.AccountSynced) def(SettingKeys.ACTIONS_ORDER_SIDEBAR, "[]", SettingScope.AccountSynced) + def(SettingKeys.NAV_MOBILE_BAR, "[]", SettingScope.AccountSynced) def(SettingKeys.ACTIONS_ORDER_MEDIA_SESSION, "[]", SettingScope.AccountSynced) def(SettingKeys.SYNC_ENABLED, "true", SettingScope.DeviceLocal) def(SettingKeys.CONNECT_COORDINATOR_URL, "null", SettingScope.DeviceLocal) diff --git a/crates/hocket-core/src/settings/registry.rs b/crates/hocket-core/src/settings/registry.rs index a201eb5..d3a4f46 100644 --- a/crates/hocket-core/src/settings/registry.rs +++ b/crates/hocket-core/src/settings/registry.rs @@ -78,6 +78,9 @@ pub mod keys { pub const ACTIONS_ORDER_CONTEXT_MENU: &str = "actions.order.contextMenu"; pub const ACTIONS_ORDER_SIDEBAR: &str = "actions.order.sidebar"; pub const ACTIONS_ORDER_MEDIA_SESSION: &str = "actions.order.mediaSession"; + /// The phone's bottom bar: ordered place ids (the app's own, not action ids), [] = its default. + /// Synced between phones; separate from the desktop sidebar's `actions.order.sidebar`. + pub const NAV_MOBILE_BAR: &str = "nav.mobileBar"; pub const SWIPE_QUEUE_START_TO_END: &str = "swipe.queue.startToEnd"; pub const SWIPE_QUEUE_END_TO_START: &str = "swipe.queue.endToStart"; pub const SWIPE_LIST_START_TO_END: &str = "swipe.list.startToEnd"; @@ -404,6 +407,7 @@ pub static REGISTRY: &[SettingDef] = &[ || json!([]), string_list_valid, ), + json_def(NAV_MOBILE_BAR, Synced, || json!([]), string_list_valid), // Swipe actions on song rows. `love` toggles (love / unlove by the row's // state); the platform runs the chosen id through `RunAction`. def( diff --git a/crates/hocket-core/src/settings/strings.rs b/crates/hocket-core/src/settings/strings.rs index 17217de..1f21824 100644 --- a/crates/hocket-core/src/settings/strings.rs +++ b/crates/hocket-core/src/settings/strings.rs @@ -37,6 +37,7 @@ pub const TEXTS: &[SettingText] = &[ t("actions.order.contextMenu", "Context menu items", "Which actions appear in context menus, and in what order."), t("actions.order.sidebar", "Sidebar items", "Which sections appear in the sidebar, and in what order."), t("actions.order.mediaSession", "Media session buttons", "Which actions appear as OS media controls, and in what order."), + t("nav.mobileBar", "Bottom bar", "Which places appear in the phone's bottom bar, and in what order."), t("swipe.queue.startToEnd", "Queue: swipe right", "What swiping a queue item towards the end of the line does (right in left-to-right languages)."), t("swipe.queue.endToStart", "Queue: swipe left", "What swiping a queue item towards the start of the line does (left in left-to-right languages)."), t("swipe.list.startToEnd", "Song lists: swipe right", "What swiping a song in an album, playlist, search results or the library does, towards the end of the line."), diff --git a/desktop/e2e/a11y.spec.ts b/desktop/e2e/a11y.spec.ts index 61cd683..35599d8 100644 --- a/desktop/e2e/a11y.spec.ts +++ b/desktop/e2e/a11y.spec.ts @@ -149,6 +149,8 @@ test.describe("axe: no violations anywhere", () => { await expect(page.getByTestId("dialog-addToPlaylist")).toBeVisible(); await expectNoViolations(page, `${theme} add-to-playlist dialog`); await page.keyboard.press("Escape"); + // Gone, exit animation included: its "New playlist…" row would match the button below. + await expect(page.getByTestId("dialog-addToPlaylist")).toHaveCount(0); // Prompt dialog (new playlist), confirmation dialog (remove server), sleep timer, Connect, track info. await page.getByTestId("nav-playlists").click(); await page.getByRole("button", { name: "New playlist" }).click(); diff --git a/desktop/e2e/chrome.spec.ts b/desktop/e2e/chrome.spec.ts index 6c9edb1..4f1e382 100644 --- a/desktop/e2e/chrome.spec.ts +++ b/desktop/e2e/chrome.spec.ts @@ -152,6 +152,22 @@ test.describe("window chrome", () => { await check(); }); + test("a sidebar place switched off in Settings stays listed and can be switched back on", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-customisation").click(); + const list = page.getByTestId("order-sidebar"); + const genres = list.getByRole("switch", { name: "Genres" }); + await expect(genres).toBeChecked(); + await genres.click(); + await expect(page.getByTestId("sidebar").getByTestId("nav-genres")).toHaveCount(0); + await expect(genres).not.toBeChecked(); + await genres.click(); + await expect(genres).toBeChecked(); + await expect(page.getByTestId("sidebar").getByTestId("nav-genres")).toBeVisible(); + }); + test("settings stays pinned below the sidebar list, and a long server row never scrolls the settings page sideways", async ({ hocket }) => { const { page } = hocket; await completeSetup(page); diff --git a/desktop/src/main/fake-core/index.ts b/desktop/src/main/fake-core/index.ts index cd723be..31c07e2 100644 --- a/desktop/src/main/fake-core/index.ts +++ b/desktop/src/main/fake-core/index.ts @@ -1802,6 +1802,7 @@ export class FakeCore implements CoreHandle { ["actions.order.contextMenu", [], "accountSynced"], ["actions.order.sidebar", [], "accountSynced"], ["actions.order.mediaSession", [], "accountSynced"], + ["nav.mobileBar", [], "accountSynced"], ["shortcuts", {}, "deviceLocal"], ["sync.enabled", true, "deviceLocal"], ["connect.coordinatorUrl", null, "deviceLocal"], diff --git a/desktop/src/renderer/views/Settings.tsx b/desktop/src/renderer/views/Settings.tsx index c5871c1..10cb279 100644 --- a/desktop/src/renderer/views/Settings.tsx +++ b/desktop/src/renderer/views/Settings.tsx @@ -13,6 +13,7 @@ import { Icon } from "../components/Icon"; import { fmtBytes, fmtRelative } from "../lib/format"; import { chordFromEvent, chordToString, formatChord, parseChord } from "../store/shortcuts"; import { useKeymap } from "../store/keyboard"; +import { CUSTOMISABLE } from "@shared/action-orders"; import { DEFAULT_KEYMAP } from "@shared/keymap"; import { executeAction } from "../store/actions"; import { CredentialWarning } from "../components/CredentialWarning"; @@ -533,11 +534,19 @@ function Customisation() { function OrderList({ surface, title }: { surface: string; title: string }) { const version = useApp((s) => s.actionsVersion); - // The full set comes from the registry (empty order = all); current order from Query.Actions. - const all = useQuery(() => ({ type: "actions", data: { surface, target: { type: surface === "contextMenu" ? "tracks" : "none", data: surface === "contextMenu" ? { ids: ["__all__"] } : undefined } as never } }), "actions", [surface, version], { static: true }); + const target = { type: surface === "contextMenu" ? "tracks" : "none", data: surface === "contextMenu" ? { ids: ["__all__"] } : undefined } as never; + // The chosen ones, in order, from Query.Actions (an empty stored order = the default); the + // switched-off rest of CUSTOMISABLE after them, described by the palette (which lists everything). + const chosen = useQuery(() => ({ type: "actions", data: { surface, target } }), "actions", [surface, version], { static: true }); + const palette = useQuery(() => ({ type: "actions", data: { surface: "palette", target } }), "actions", [surface], { static: true }); const [items, setItems] = useState<{ a: ActionDescriptor; on: boolean }[]>([]); const [dragIdx, setDragIdx] = useState(undefined); - useEffect(() => { if (all.data) setItems(all.data.map((a) => ({ a, on: true }))); }, [all.data]); + useEffect(() => { + if (!chosen.data || !palette.data) return; + const on = new Set(chosen.data.map((a) => a.id)); + const off = (CUSTOMISABLE[surface] ?? []).filter((id) => !on.has(id)).flatMap((id) => palette.data?.filter((a) => a.id === id) ?? []); + setItems([...chosen.data.map((a) => ({ a, on: true })), ...off.map((a) => ({ a, on: false }))]); + }, [chosen.data, palette.data, surface]); const commit = (next: typeof items) => { setItems(next); bridge().dispatch({ type: "setActionOrder", data: { surface, action_ids: next.filter((x) => x.on).map((x) => x.a.id) } }); diff --git a/desktop/src/shared/action-orders.ts b/desktop/src/shared/action-orders.ts new file mode 100644 index 0000000..ab1a6e2 --- /dev/null +++ b/desktop/src/shared/action-orders.ts @@ -0,0 +1,10 @@ +// What each choose-and-order surface can hold, so Settings can list the +// actions that are switched off too (the core's Actions query returns only +// the chosen ones). Mirrors the default lists in +// crates/hocket-core/src/actions/defs.rs; anything else already in a stored +// order (navigateLibrary from an older phone build) is listed while it is on. +export const CUSTOMISABLE: Record = { + sidebar: ["navigateHome", "navigateTracks", "navigateAlbums", "navigateArtists", "navigatePlaylists", "navigateGenres", "navigateRecent", "navigateFilters", "navigateDownloads", "navigateStats", "navigateSettings"], + contextMenu: ["play", "playShuffled", "playNext", "playLater", "addToPlaylist", "love", "unlove", "rate5", "rate4", "rate3", "rate2", "rate1", "rate0", "download", "unpin", "goToAlbum", "goToArtist", "removeFromQueue", "removeFromPlaylist", "restoreSavedQueue", "pinSavedQueue", "unpinSavedQueue", "saveQueueAsPlaylist", "deleteSavedQueue", "deletePlaylist"], + mediaSession: ["previous", "togglePlay", "next", "shuffle", "repeat", "love"], +};