Skip to content
Open
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
62 changes: 61 additions & 1 deletion app/smartphone/src/main/java/com/m3u/smartphone/ui/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,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
Expand Down Expand Up @@ -451,6 +454,59 @@ private fun AppImpl(
}
}

/**
* 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
* 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.
*
* 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(
actions: List<Action>,
modifier: Modifier = Modifier,
) {
// 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<PagingData<ChannelWithProgramme>>,
Expand Down Expand Up @@ -513,7 +569,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(actions = Metadata.actions)
}
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be better to do these in a single database transaction?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, but I don't think there's anything to wrap here — please tell me if I misread you.

The loop doesn't write to the database. PlaylistRepository.refresh enqueues a SubscriptionWorker per playlist and returns; the only read is the initial getAll(). A transaction would enclose no writes, and holding one open across enqueue calls would keep it open for the WorkManager round-trips.

If your concern was that a playlist added while the loop runs could be missed — that's real, and a snapshot read would fix it. Happy to do that if it's what you had in mind.

}
}

val series = MutableStateFlow<Channel?>(null)
val seriesReplay = MutableStateFlow(0)
val episodes: StateFlow<Resource<List<XtreamEpisodeInfo>>> = series
Expand Down
1 change: 0 additions & 1 deletion data/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
/build
/src/test
1 change: 1 addition & 0 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions data/src/main/java/com/m3u/data/database/dao/ChannelDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<String>

/**
* 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<ChannelUserState>

@Query("SELECT * FROM streams WHERE seen != 0 ORDER BY seen DESC LIMIT 1")
suspend fun getPlayedRecently(): Channel?

Expand Down
59 changes: 59 additions & 0 deletions data/src/main/java/com/m3u/data/database/model/ChannelUserState.kt
Original file line number Diff line number Diff line change
@@ -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<ChannelUserState>) {
private val byRelationId: Map<String, ChannelUserState> = states
.mapNotNull { state ->
state.relationId?.takeIf(String::isNotBlank)?.let { id -> id to state }
}
.toMap()

private val byUrl: Map<String, ChannelUserState> = 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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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())
Expand Down Expand Up @@ -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)
}
Expand All @@ -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())
Expand Down
12 changes: 12 additions & 0 deletions data/src/main/java/com/m3u/data/worker/SubscriptionWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down
Loading
Loading