From 5f8eab04e16e2a8b4be3bf855eb7a6a924fa6f82 Mon Sep 17 00:00:00 2001 From: Ingo Wolf Date: Fri, 9 Oct 2026 17:27:34 +1100 Subject: [PATCH 1/4] core: a synced nav.mobileBar setting for the phone's bottom bar The phone's bar gets its own synced key, so it no longer has to share actions.order.sidebar with the desktop sidebar. Mirrored in both fake cores. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y13F1MZyepBvGTi1ceeGUc --- android/core/src/main/java/app/hocket/core/SettingKeys.kt | 1 + android/core/src/main/java/app/hocket/core/fake/FakeCore.kt | 1 + crates/hocket-core/src/settings/registry.rs | 4 ++++ crates/hocket-core/src/settings/strings.rs | 1 + desktop/src/main/fake-core/index.ts | 1 + 5 files changed, 8 insertions(+) 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/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"], From 8cd3f7b738e5ba105a49e20c2c8bf6d9239aef27 Mon Sep 17 00:00:00 2001 From: Ingo Wolf Date: Fri, 9 Oct 2026 17:27:38 +1100 Subject: [PATCH 2/4] android: the bottom bar syncs as nav.mobileBar, not the desktop sidebar Saving the bar used to overwrite the synced desktop sidebar order with the phone's two to five places (Library showing up as Albums on desktop). The bar now lives in its own synced setting, shared between phones, and the desktop sidebar is left alone. A bar saved device-locally before this is adopted once when the account has none yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y13F1MZyepBvGTi1ceeGUc --- .../app/src/main/java/app/hocket/AppPrefs.kt | 40 +++++++++++++++- .../main/java/app/hocket/ui/nav/AppRoot.kt | 2 +- .../main/java/app/hocket/ui/nav/BottomBar.kt | 28 +++++++---- .../src/main/java/app/hocket/ui/nav/Routes.kt | 30 ++++++------ .../app/hocket/ui/NavBarCustomisationTest.kt | 48 +++++++++++-------- 5 files changed, 101 insertions(+), 47 deletions(-) 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"))) From 73b29367cd6fe0c8564e0e55d6e4fc42930431dd Mon Sep 17 00:00:00 2001 From: Ingo Wolf Date: Fri, 9 Oct 2026 17:29:10 +1100 Subject: [PATCH 3/4] =?UTF-8?q?desktop:=20switched-off=20actions=20stay=20?= =?UTF-8?q?listed=20in=20Settings=20=E2=86=92=20Customisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choose-and-order lists were built from Query.Actions, which returns only the chosen actions, so switching one off removed it from the list for good. The switched-off rest of each surface's set is now listed after the chosen ones, described by the palette. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y13F1MZyepBvGTi1ceeGUc --- desktop/e2e/chrome.spec.ts | 16 ++++++++++++++++ desktop/src/renderer/views/Settings.tsx | 15 ++++++++++++--- desktop/src/shared/action-orders.ts | 10 ++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 desktop/src/shared/action-orders.ts 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/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"], +}; From a26a5fec0bdb47a6d4f546c08316ed940cd641ec Mon Sep 17 00:00:00 2001 From: Ingo Wolf Date: Fri, 9 Oct 2026 17:47:44 +1100 Subject: [PATCH 4/4] desktop e2e: wait for the add-to-playlist dialog to close before New playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape starts the dialog's exit animation; until it unmounts, its "New playlist…" row also matches the Playlists view's New playlist button, so the click raced the animation and failed under strict mode. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y13F1MZyepBvGTi1ceeGUc --- desktop/e2e/a11y.spec.ts | 2 ++ 1 file changed, 2 insertions(+) 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();