From ac66d8c0c4240d2a75faceba30af540af042ed36 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:42:32 +0200 Subject: [PATCH 1/4] fix(ui): surface screen actions the top bar never renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playlist screen publishes a sort and a refresh into Metadata.actions, fully wired down to SubscriptionWorker. Neither has ever been reachable. Those actions are only rendered by the TopAppBar, and App.kt renders it only when shouldShowContextualTopBar holds — that is, isRootPlaylistConfiguration || isNestedDetailVisible. On the playlist screen both are false and the search bar takes its place, so the actions were declared, wired, and impossible to invoke. Updating a catalogue meant subscribing to it again, retyping the address and credentials. The search bar already drew an overflow icon, and that icon did nothing at all: a bare Icon, no IconButton, no onClick. It now opens a menu built from Metadata.actions — generic, so any screen publishing actions gets them with no further change here, and the sort comes back along with the refresh. Hidden when the search is expanded, since the actions belong to the screen underneath, and absent entirely when a screen publishes none rather than opening an empty menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/m3u/smartphone/ui/App.kt | 57 ++++++++++++++++++- i18n/src/main/res/values/ui.xml | 2 + 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt index 1b34ce0ea..1a5718a24 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt @@ -44,6 +44,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBarDefaults @@ -451,6 +453,55 @@ private fun AppImpl( } } +/** + * The overflow menu of the search bar, holding whatever the current screen + * published in [Metadata.actions]. + * + * Those actions used to be unreachable. They are only ever rendered by the + * TopAppBar, which shouldShowContextualTopBar keeps off every screen showing + * the search bar instead — the playlist screen among them. So its refresh and + * its sort existed, fully wired, and nothing on screen could invoke them: the + * only way to update a catalogue was to subscribe to it again, credentials and + * all. + * + * Reading the actions rather than naming them keeps this generic: any screen + * publishing actions gets them here, with no change to this file. + */ +@Composable +private fun ScreenActionsMenu(modifier: Modifier = Modifier) { + val actions = Metadata.actions + // No icon at all rather than one opening an empty menu. + if (actions.isEmpty()) return + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(string.ui_cd_more_actions), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.contentDescription.orEmpty()) }, + leadingIcon = { + Icon(imageVector = action.icon, contentDescription = null) + }, + enabled = action.enabled, + onClick = { + expanded = false + action.onClick() + }, + ) + } + } + } +} + private class AppContentArguments( val navController: NavHostController, val channels: Flow>, @@ -513,7 +564,11 @@ private fun AppContent( } }, trailingIcon = { - Icon(Icons.Default.MoreVert, contentDescription = null) + // Hidden while the search is expanded: these actions belong to + // the screen underneath, not to the search results. + if (searchBarState.currentValue != SearchBarValue.Expanded) { + ScreenActionsMenu() + } }, ) } diff --git a/i18n/src/main/res/values/ui.xml b/i18n/src/main/res/values/ui.xml index 24210636f..ea1cceea1 100644 --- a/i18n/src/main/res/values/ui.xml +++ b/i18n/src/main/res/values/ui.xml @@ -116,4 +116,6 @@ %1$d days Never + + More actions From b8663374650227e9b1519b3bffe5454c39fdf237 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:42:32 +0200 Subject: [PATCH 2/4] fix(playlist): keep watch history and favourites across a refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing a catalogue deletes every channel and imports it anew, so rows came back with seen reset and the Continue watching row emptied. Favourites survived only under PlaylistStrategy.KEEP, and KEEP is not the default. What a viewer built up is now captured before the delete and handed to the rows that replace them, matched on relation_id — or on the URL, for an M3U playlist without tvg-id, which has no stable id to match on. Only rows that differ from a fresh import are carried: a catalogue runs to tens of thousands of channels, of which a handful were ever watched. Taking the rest would mean holding the whole thing in memory to restore nothing. Also fixes a crash this made reachable for the first time. WorkManager calls getForegroundInfo before doWork, and doWork was where the notification channel got created, so the first refresh of an install handed startForeground a notification pointing at a channel that did not exist: RemoteServiceException: Bad notification for startForeground: invalid channel for service notification That killed the process mid-import. It went unnoticed because the channel survives once created — and because nothing on screen could trigger a refresh to begin with. Verified on device, 40 971 channels: after a full refresh the counts return identical, the 22 watched channels keep their timestamps, Continue watching still shows the same title, and the 28 154 cached descriptions are intact. Co-Authored-By: Claude Opus 5 (1M context) --- data/.gitignore | 1 - data/build.gradle.kts | 1 + .../com/m3u/data/database/dao/ChannelDao.kt | 19 +++ .../data/database/model/ChannelUserState.kt | 59 ++++++++ .../playlist/PlaylistRepositoryImpl.kt | 28 +++- .../com/m3u/data/worker/SubscriptionWorker.kt | 12 ++ .../database/model/PreservedUserStatesTest.kt | 129 ++++++++++++++++++ 7 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt create mode 100644 data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt diff --git a/data/.gitignore b/data/.gitignore index c8b0aa09c..796b96d1c 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1 @@ /build -/src/test diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 103778ad6..df4c13b52 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -169,6 +169,7 @@ dependencies { implementation(libs.jakewharton.disklrucache) + testImplementation(kotlin("test-junit")) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.core) diff --git a/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt index 1525e4e10..f7fc94b20 100644 --- a/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt +++ b/data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt @@ -11,6 +11,7 @@ import androidx.room.Upsert import com.m3u.data.database.model.AdjacentChannels import com.m3u.data.database.model.Channel import com.m3u.data.database.model.ChannelMetadataBase +import com.m3u.data.database.model.ChannelUserState import com.m3u.data.database.model.ExtensionChannelMetadataOverlay import kotlinx.coroutines.flow.Flow @@ -223,6 +224,24 @@ interface ChannelDao { @Query("SELECT url FROM streams WHERE relation_id IS NULL AND playlist_url IN (:playlistUrls) AND (favourite = 1 OR hidden = 1)") suspend fun getFavOrHiddenUrlsByPlaylistUrlNotContainsRelationId(vararg playlistUrls: String): List + /** + * Everything a viewer built up on the channels of a playlist, so a refresh + * can hand it back to the rows that replace them. + * + * Only rows that differ from a fresh import are returned. A catalogue runs + * to tens of thousands of channels, of which a handful were ever watched or + * favourited; carrying the rest would mean holding the whole thing in + * memory to restore nothing. + */ + @Query( + """ + SELECT relation_id, url, seen, favourite, hidden FROM streams + WHERE playlist_url = :playlistUrl + AND (seen != 0 OR favourite = 1 OR hidden = 1) + """ + ) + suspend fun getUserStateByPlaylistUrl(playlistUrl: String): List + @Query("SELECT * FROM streams WHERE seen != 0 ORDER BY seen DESC LIMIT 1") suspend fun getPlayedRecently(): Channel? diff --git a/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt b/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt new file mode 100644 index 000000000..aad676b91 --- /dev/null +++ b/data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt @@ -0,0 +1,59 @@ +package com.m3u.data.database.model + +import androidx.room.ColumnInfo + +/** + * What a viewer built up on a channel, as opposed to what the provider says + * about it. + * + * Refreshing a catalogue deletes every channel and imports them anew, so rows + * come back with their id regenerated and these columns reset — the Continue + * watching row empties, favourites are lost. Carried across the import, none of + * that is. + */ +data class ChannelUserState( + /** Stable across imports when the provider gives one; M3U often does not. */ + @ColumnInfo(name = "relation_id") + val relationId: String?, + @ColumnInfo(name = "url") + val url: String, + @ColumnInfo(name = "seen") + val seen: Long, + @ColumnInfo(name = "favourite") + val favourite: Boolean, + @ColumnInfo(name = "hidden") + val hidden: Boolean, +) + +/** + * Looks a channel up by whichever identity survived the import. + * + * Xtream channels keep a relation id; an M3U playlist without tvg-id has none, + * and only its URL to go on. Falling back to the URL keeps both kinds covered + * without the callers having to know which is which. + */ +class PreservedUserStates(states: List) { + private val byRelationId: Map = states + .mapNotNull { state -> + state.relationId?.takeIf(String::isNotBlank)?.let { id -> id to state } + } + .toMap() + + private val byUrl: Map = states.associateBy(ChannelUserState::url) + + val isEmpty: Boolean get() = byRelationId.isEmpty() && byUrl.isEmpty() + + fun of(relationId: String?, url: String): ChannelUserState? = + relationId?.takeIf(String::isNotBlank)?.let(byRelationId::get) ?: byUrl[url] +} + +/** + * Hands a freshly imported channel back what its predecessor had earned. + * + * Returns the channel untouched when nothing was preserved for it, which is the + * case for all but a handful of a catalogue. + */ +fun Channel.restoring(states: PreservedUserStates?, relationId: String?): Channel { + val state = states?.of(relationId ?: this.relationId, url) ?: return this + return copy(seen = state.seen, favourite = state.favourite, hidden = state.hidden) +} diff --git a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt index 1f0fb319c..53b230dde 100644 --- a/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt +++ b/data/src/main/java/com/m3u/data/repository/playlist/PlaylistRepositoryImpl.kt @@ -21,6 +21,8 @@ import com.m3u.data.database.dao.PlaylistDao import com.m3u.data.database.dao.ProgrammeDao import com.m3u.data.database.dao.ProviderDao import com.m3u.data.database.model.Channel +import com.m3u.data.database.model.PreservedUserStates +import com.m3u.data.database.model.restoring import com.m3u.data.database.model.DataSource import com.m3u.data.database.model.Playlist import com.m3u.data.database.model.PlaylistWithChannels @@ -253,6 +255,10 @@ internal class PlaylistRepositoryImpl @Inject constructor( // The previous row may have been saved as EPG or another source. source = DataSource.M3U, ) ?: Playlist(title, internalUrl, source = DataSource.M3U) + // Captured before the delete: a refresh re-imports every + // channel, and these columns would otherwise come back reset. + val preservedUserStates = + PreservedUserStates(channelDao.getUserStateByPlaylistUrl(internalUrl)) deleteChannelsForImport(internalUrl, playlistStrategy) playlistDao.insertOrReplace(playlist) staging.forEachBatch(BUFFER_M3U_CAPACITY) { staged -> @@ -269,7 +275,12 @@ internal class PlaylistRepositoryImpl @Inject constructor( } ) } - .map(StagedChannel::channel) + .map { record -> + record.channel.restoring( + preservedUserStates, + record.preservationRelationId, + ) + } .toList() if (channelsToInsert.isNotEmpty()) { channelDao.insertOrReplaceAll(*channelsToInsert.toTypedArray()) @@ -414,6 +425,14 @@ internal class PlaylistRepositoryImpl @Inject constructor( } else -> emptyMap() } + // Captured before the delete below wipes it. Refreshing a + // catalogue re-imports every channel from scratch, so + // without this the Continue watching row empties and + // favourites are lost every single time. + val userStateByPlaylistUrl = requiredPlaylistUrls + .associateWith { playlistUrl -> + PreservedUserStates(channelDao.getUserStateByPlaylistUrl(playlistUrl)) + } val requiredPlaylists = requiredPlaylistUrls.map { playlistUrl -> currentXtreamPlaylist(title, playlistUrl) } @@ -433,7 +452,12 @@ internal class PlaylistRepositoryImpl @Inject constructor( favOrHiddenRelationIdsByPlaylistUrl, ) } - .map(StagedChannel::channel) + .map { record -> + record.channel.restoring( + userStateByPlaylistUrl[record.channel.playlistUrl], + record.preservationRelationId, + ) + } .toList() if (channelsToInsert.isNotEmpty()) { channelDao.insertOrReplaceAll(*channelsToInsert.toTypedArray()) diff --git a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt index ea07d1865..616d5f95b 100644 --- a/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt +++ b/data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt @@ -242,6 +242,18 @@ class SubscriptionWorker @AssistedInject constructor( } override suspend fun getForegroundInfo(): ForegroundInfo { + // WorkManager calls this before doWork, which is where the channel used + // to be created — so on the very first run of an install the channel did + // not exist yet and startForeground was handed a notification pointing + // at nothing: + // + // RemoteServiceException: Bad notification for startForeground: + // invalid channel for service notification + // + // That killed the process. It stayed unnoticed because the channel + // survives once created, and because nothing on screen could trigger a + // refresh in the first place. + createChannel() return ForegroundInfo(notificationId, createN10nBuilder().build()) } diff --git a/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt b/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt new file mode 100644 index 000000000..853ce4b9f --- /dev/null +++ b/data/src/test/java/com/m3u/data/database/model/PreservedUserStatesTest.kt @@ -0,0 +1,129 @@ +package com.m3u.data.database.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Refreshing a catalogue deletes every channel and imports it anew, which used + * to reset what the viewer had built up: the Continue watching row emptied and + * favourites disappeared on every update. These pin down the hand-over. + */ +class PreservedUserStatesTest { + @Test + fun `a watched channel keeps its history across an import`() { + val states = PreservedUserStates( + listOf(state(relationId = "12345", url = OLD_URL, seen = 1_700_000L)), + ) + + // The provider may hand back a different URL for the same title. + val imported = channel(relationId = "12345", url = NEW_URL) + val restored = imported.restoring(states, relationId = "12345") + + assertEquals(1_700_000L, restored.seen) + assertEquals(NEW_URL, restored.url) + } + + @Test + fun `favourite and hidden flags come back too`() { + val states = PreservedUserStates( + listOf(state(relationId = "1", url = OLD_URL, favourite = true, hidden = true)), + ) + + val restored = channel(relationId = "1", url = NEW_URL).restoring(states, "1") + + assertTrue(restored.favourite) + assertTrue(restored.hidden) + } + + @Test + fun `a playlist without relation ids falls back to the url`() { + // An M3U without tvg-id has no stable id; the URL is all there is. + val states = PreservedUserStates( + listOf(state(relationId = null, url = OLD_URL, seen = 42L)), + ) + + val restored = channel(relationId = null, url = OLD_URL).restoring(states, null) + + assertEquals(42L, restored.seen) + } + + @Test + fun `a channel nobody touched is returned untouched`() { + val states = PreservedUserStates( + listOf(state(relationId = "known", url = OLD_URL, seen = 1L)), + ) + + val imported = channel(relationId = "other", url = NEW_URL) + // Same instance, not a copy: nothing to hand over. + assertSame(imported, imported.restoring(states, "other")) + assertEquals(0L, imported.restoring(states, "other").seen) + } + + @Test + fun `a blank relation id does not match every blank one`() { + val states = PreservedUserStates( + listOf(state(relationId = "", url = OLD_URL, seen = 99L)), + ) + + // Falls through to the URL rather than matching anything blank. + assertEquals(0L, channel(relationId = "", url = NEW_URL).restoring(states, "").seen) + assertEquals(99L, channel(relationId = "", url = OLD_URL).restoring(states, "").seen) + } + + @Test + fun `nothing preserved leaves the import alone`() { + val empty = PreservedUserStates(emptyList()) + assertTrue(empty.isEmpty) + + val imported = channel(relationId = "1", url = NEW_URL) + assertSame(imported, imported.restoring(empty, "1")) + assertSame(imported, imported.restoring(null, "1")) + } + + @Test + fun `the relation id wins over the url when both are known`() { + val states = PreservedUserStates( + listOf( + state(relationId = "moved", url = OLD_URL, seen = 10L), + state(relationId = "other", url = NEW_URL, seen = 20L), + ), + ) + + // Same title, new URL: its own history follows it, not the one that + // happens to sit at that URL now. + val restored = channel(relationId = "moved", url = NEW_URL).restoring(states, "moved") + assertEquals(10L, restored.seen) + assertFalse(restored.favourite) + } + + private fun state( + relationId: String?, + url: String, + seen: Long = 0L, + favourite: Boolean = false, + hidden: Boolean = false, + ) = ChannelUserState( + relationId = relationId, + url = url, + seen = seen, + favourite = favourite, + hidden = hidden, + ) + + private fun channel(relationId: String?, url: String) = Channel( + url = url, + category = "Films", + title = "Le Prénom", + playlistUrl = PLAYLIST_URL, + relationId = relationId, + ) + + private companion object { + const val PLAYLIST_URL = "http://example.test/playlist" + const val OLD_URL = "http://example.test/movie/1.mkv" + const val NEW_URL = "http://example.test/movie/2.mkv" + } +} From 1702e805600770bfd30b4477972e790b0b6dc907 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 09:21:53 +0200 Subject: [PATCH 3/4] feat(foryou): refresh every playlist from the home screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing lives on the playlist screen and acts on the one playlist it shows, so keeping a few sources up to date means opening each of them in turn — from the very screen that already lists them all side by side. The home screen now offers a refresh of its own, reusing PlaylistRepository.refresh for each source. Playlists that cannot be refreshed at all, such as one imported from a local file, are skipped rather than reported as failing. Co-Authored-By: Claude Opus 5 (1M context) --- .../ui/business/foryou/ForyouScreen.kt | 10 ++++++++++ .../com/m3u/business/foryou/ForyouViewModel.kt | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt index 70403fca0..ee069eace 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/business/foryou/ForyouScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -79,6 +80,7 @@ fun ForyouRoute( val title = stringResource(string.ui_title_foryou) val addContentDescription = stringResource(string.ui_action_add) + val refreshContentDescription = stringResource(string.ui_action_refresh) val playlists by viewModel.playlists.collectAsStateWithLifecycle() val specs by viewModel.specs.collectAsStateWithLifecycle() @@ -97,6 +99,14 @@ fun ForyouRoute( icon = Icons.Rounded.Add, contentDescription = addContentDescription, onClick = navigateToSettingPlaylistManagement + ), + // Refreshing lives on the playlist screen, one playlist at a time — + // which means opening each of them in turn, from the very screen + // that already lists them all. + Action( + icon = Icons.Rounded.Refresh, + contentDescription = refreshContentDescription, + onClick = viewModel::onRefreshAllPlaylists ) ) onPauseOrDispose { diff --git a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt index 1d5aeeaee..86cdc925e 100644 --- a/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt +++ b/business/foryou/src/main/java/com/m3u/business/foryou/ForyouViewModel.kt @@ -13,6 +13,7 @@ import com.m3u.core.foundation.wrapper.mapResource import com.m3u.core.foundation.wrapper.resource import com.m3u.data.database.model.Channel import com.m3u.data.database.model.Playlist +import com.m3u.data.database.model.refreshable import com.m3u.data.parser.xtream.XtreamEpisodeInfo import com.m3u.data.repository.channel.ChannelRepository import com.m3u.data.repository.playlist.PlaylistRepository @@ -114,6 +115,22 @@ class ForyouViewModel @Inject constructor( } } + /** + * Refreshes every playlist that can be refreshed. + * + * The playlist screen refreshes the one it shows, which means going into + * each of them in turn — and this screen is precisely where they are all + * in view. Sources that cannot be refreshed at all, such as a playlist + * imported from a local file, are skipped rather than reported as failing. + */ + fun onRefreshAllPlaylists() { + viewModelScope.launch { + playlistRepository.getAll() + .filter { playlist -> playlist.refreshable } + .forEach { playlist -> playlistRepository.refresh(playlist.url) } + } + } + val series = MutableStateFlow(null) val seriesReplay = MutableStateFlow(0) val episodes: StateFlow>> = series From 8580bb8ae91c85e0fe770b4b5bb04bb1e78d4b04 Mon Sep 17 00:00:00 2001 From: Pierroons <97373452+Pierroons@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:53:08 +0200 Subject: [PATCH 4/4] refactor(ui): take the actions as a parameter instead of reading Metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composable took no parameters and recomposed anyway, because it read Metadata.actions from inside — a global mutableStateOf. Nothing in its signature said so, which is confusing for a reader trying to work out what makes it redraw. The caller already sits where that state is read, so it passes the list in. The composable is a function of its arguments again, and stays just as generic: any screen publishing actions still gets them. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/m3u/smartphone/ui/App.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt index 1a5718a24..e4e60192c 100644 --- a/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt +++ b/app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt @@ -99,6 +99,7 @@ import com.m3u.smartphone.ui.common.AppNavHost import com.m3u.smartphone.ui.common.connect.RemoteControlSheet import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue import com.m3u.smartphone.ui.common.helper.LocalHelper +import com.m3u.smartphone.ui.common.helper.Action import com.m3u.smartphone.ui.common.helper.Metadata import com.m3u.smartphone.ui.material.components.Destination import com.m3u.smartphone.ui.material.components.SnackHost @@ -454,8 +455,8 @@ private fun AppImpl( } /** - * The overflow menu of the search bar, holding whatever the current screen - * published in [Metadata.actions]. + * The overflow menu of the search bar, holding the actions the current screen + * published. * * Those actions used to be unreachable. They are only ever rendered by the * TopAppBar, which shouldShowContextualTopBar keeps off every screen showing @@ -464,12 +465,16 @@ private fun AppImpl( * only way to update a catalogue was to subscribe to it again, credentials and * all. * - * Reading the actions rather than naming them keeps this generic: any screen - * publishing actions gets them here, with no change to this file. + * Taking the actions as a parameter rather than reading Metadata here keeps + * this a function of its arguments: what makes it recompose is visible in its + * signature. It also stays generic — any screen publishing actions gets them, + * with no change to this file. */ @Composable -private fun ScreenActionsMenu(modifier: Modifier = Modifier) { - val actions = Metadata.actions +private fun ScreenActionsMenu( + actions: List, + modifier: Modifier = Modifier, +) { // No icon at all rather than one opening an empty menu. if (actions.isEmpty()) return var expanded by remember { mutableStateOf(false) } @@ -567,7 +572,7 @@ private fun AppContent( // Hidden while the search is expanded: these actions belong to // the screen underneath, not to the search results. if (searchBarState.currentValue != SearchBarValue.Expanded) { - ScreenActionsMenu() + ScreenActionsMenu(actions = Metadata.actions) } }, )