diff --git a/android/app/src/main/java/app/hocket/AppPrefs.kt b/android/app/src/main/java/app/hocket/AppPrefs.kt index 9b1c4934..4d23e776 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 1ec3b9df..bd2451a8 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 479bad0e..5b0f9121 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 72021a97..9353d481 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 0ba1f8f3..f9bd4c7f 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 a8f7cd4d..fbdc9943 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 6222a8b8..d31fc6ca 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 a201eb50..d3a4f465 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 17217de8..1f21824e 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 61cd6833..35599d8d 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 6c9edb18..4f1e382c 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 cd723bea..31c07e22 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 c5871c14..10cb279f 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 00000000..ab1a6e25 --- /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"], +};