Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion android/app/src/main/java/app/hocket/AppPrefs.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
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
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<Preferences> by preferencesDataStore(name = "hocket-app")

Expand All @@ -18,6 +27,35 @@ interface NavBarPrefs {
suspend fun setNavItems(ids: List<String>)
}

/**
* 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<List<String>> = 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<String>) {
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.
Expand All @@ -27,7 +65,7 @@ class AppPrefs(private val store: DataStore<Preferences>) : 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<List<String>> = store.data.map { p -> p[navItemsKey]?.split(',')?.filter { it.isNotBlank() } ?: emptyList() }

override suspend fun setNavItems(ids: List<String>) {
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 19 additions & 9 deletions android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NavBarPrefs?> { 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]). */
Expand Down Expand Up @@ -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<NavItem>) -> 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()))
}
}
}
Expand Down
30 changes: 15 additions & 15 deletions android/app/src/main/java/app/hocket/ui/nav/Routes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 27 additions & 21 deletions android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<NavBarPrefs>(AppPrefs(store1))
var generation by mutableIntStateOf(0)
fun addReorderAndRemovePersistAcrossRecreation() {
val core = TestCore(startPlaying = false)
var prefs by mutableStateOf<NavBarPrefs>(SyncedNavBarPrefs(core.client))
var generation by mutableIntStateOf(0)
compose.setThemedContent(core) {
key(generation) { CompositionLocalProvider(LocalNavBarPrefs provides prefs) { AppRoot(core.client) } }
}
Expand All @@ -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<String>(), 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<String>(), local.navItems.first())
}

@Test
fun theBarHoldsTwoToFiveItemsAndSaysWhy() {
start(InMemoryNavBarPrefs(listOf("home", "search", "library", "albums", "songs")))
Expand Down
1 change: 1 addition & 0 deletions android/core/src/main/java/app/hocket/core/SettingKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions crates/hocket-core/src/settings/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions crates/hocket-core/src/settings/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down
2 changes: 2 additions & 0 deletions desktop/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions desktop/e2e/chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions desktop/src/main/fake-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading
Loading