Add Live Streams screen accessible from the left drawer

Introduces a dedicated "Live Streams" screen that mirrors the Shorts
architecture (FeedContentState + FilterAssembler + SubAssembler) and
reuses the Discovery/LiveStreams rendering (ChannelCardCompose) and
relay filter builders (makeLiveActivitiesFilter) for the same top-nav
filters available on Shorts: Following, Global, Hashtag, Location,
Authors, Community, etc.

Refactors LocalPreferences.innerLoadCurrentAccountFromEncryptedStorage
to extract follow-list pref reads into a small helper so the method
stays under the JVM 65KB method size limit after adding the new
defaultLiveStreamsFollowList setting.
This commit is contained in:
Claude
2026-04-21 22:10:45 +00:00
parent 78b7fd228f
commit 3de0814e4f
17 changed files with 707 additions and 33 deletions
@@ -103,6 +103,7 @@ private object PrefKeys {
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
const val DEFAULT_LIVE_STREAMS_FOLLOW_LIST = "defaultLiveStreamsFollowList"
const val DEFAULT_LONGS_FOLLOW_LIST = "defaultLongsFollowList"
const val DEFAULT_ARTICLES_FOLLOW_LIST = "defaultArticlesFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
@@ -348,6 +349,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
putString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLiveStreamsFollowList.value))
putString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultLongsFollowList.value))
putString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultArticlesFollowList.value))
@@ -506,17 +508,7 @@ object LocalPreferences {
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null)
val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
val defaultNotificationFollowListStr = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null)
val defaultDiscoveryFollowListStr = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null)
val defaultPollsFollowListStr = getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null)
val defaultPicturesFollowListStr = getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null)
val defaultProductsFollowListStr = getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null)
val defaultShortsFollowListStr = getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null)
val defaultLongsFollowListStr = getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null)
val defaultArticlesFollowListStr = getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null)
val followListPrefs = loadFollowListPrefs()
val zapPaymentRequestServerStr = getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)
val nwcWalletsStr = getString(PrefKeys.NWC_WALLETS, null)
@@ -547,18 +539,6 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" }
val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) }
val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) }
val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) }
val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) }
val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) }
val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) }
val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) }
val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) }
val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) }
val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) }
val nwcWalletsLoaded =
async {
val nwcWalletEntries = parseOrNull<List<NwcWalletEntry>>(nwcWalletsStr)
@@ -625,16 +605,17 @@ object LocalPreferences {
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()),
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList.await()),
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList.await()),
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList.await()),
defaultPollsFollowList = MutableStateFlow(defaultPollsFollowList.await()),
defaultPicturesFollowList = MutableStateFlow(defaultPicturesFollowList.await()),
defaultProductsFollowList = MutableStateFlow(defaultProductsFollowList.await()),
defaultShortsFollowList = MutableStateFlow(defaultShortsFollowList.await()),
defaultLongsFollowList = MutableStateFlow(defaultLongsFollowList.await()),
defaultArticlesFollowList = MutableStateFlow(defaultArticlesFollowList.await()),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
defaultLiveStreamsFollowList = MutableStateFlow(followListPrefs.liveStreams),
defaultLongsFollowList = MutableStateFlow(followListPrefs.longs),
defaultArticlesFollowList = MutableStateFlow(followListPrefs.articles),
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
defaultNwcWalletId = MutableStateFlow(nwcWalletsLoaded.await().second),
hideDeleteRequestDialog = hideDeleteRequestDialog,
@@ -686,6 +667,35 @@ object LocalPreferences {
}
}
private data class FollowListPrefs(
val home: TopFilter,
val stories: TopFilter,
val notification: TopFilter,
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
val liveStreams: TopFilter,
val longs: TopFilter,
val articles: TopFilter,
)
private fun SharedPreferences.loadFollowListPrefs(): FollowListPrefs =
FollowListPrefs(
home = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null), TopFilter.AllFollows),
stories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null), TopFilter.Global),
notification = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null), TopFilter.Global),
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
liveStreams = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LIVE_STREAMS_FOLLOW_LIST, null), TopFilter.Global),
longs = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_LONGS_FOLLOW_LIST, null), TopFilter.Global),
articles = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_ARTICLES_FOLLOW_LIST, null), TopFilter.AllFollows),
)
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
if (value.isNullOrEmpty() || value == "null") {
return null
@@ -476,6 +476,9 @@ class Account(
val liveShortsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultShortsFollowList)
val liveShortsFollowListsPerRelay = OutboxLoaderState(liveShortsFollowLists, cache, scope).flow
val liveLiveStreamsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultLiveStreamsFollowList)
val liveLiveStreamsFollowListsPerRelay = OutboxLoaderState(liveLiveStreamsFollowLists, cache, scope).flow
val liveLongsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultLongsFollowList)
val liveLongsFollowListsPerRelay = OutboxLoaderState(liveLongsFollowLists, cache, scope).flow
@@ -185,6 +185,7 @@ class AccountSettings(
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLiveStreamsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
@@ -509,6 +510,17 @@ class AccountSettings(
}
}
fun changeDefaultLiveStreamsFollowList(name: FeedDefinition) {
changeDefaultLiveStreamsFollowList(name.code)
}
fun changeDefaultLiveStreamsFollowList(name: TopFilter) {
if (defaultLiveStreamsFollowList.value != name) {
defaultLiveStreamsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultLongsFollowList(name: FeedDefinition) {
changeDefaultLongsFollowList(name.code)
}
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.datasource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
@@ -101,6 +102,7 @@ class RelaySubscriptionsCoordinator(
val pictures = PicturesFilterAssembler(client)
val products = ProductsFilterAssembler(client)
val shorts = ShortsFilterAssembler(client)
val liveStreams = LiveStreamsFilterAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
@@ -123,6 +125,7 @@ class RelaySubscriptionsCoordinator(
products,
shorts,
followPacksList,
liveStreams,
longs,
articles,
badges,
@@ -65,6 +65,7 @@ object ScrollStateKeys {
const val PRODUCTS_SCREEN = "ProductsFeed"
const val SHORTS_SCREEN = "ShortsFeed"
const val FOLLOW_PACKS_SCREEN = "FollowPacksFeed"
const val LIVE_STREAMS_SCREEN = "LiveStreamsFeed"
const val LONGS_SCREEN = "LongsFeed"
const val ARTICLES_SCREEN = "ArticlesFeed"
@@ -128,6 +128,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.FollowPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleListMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
@@ -241,6 +242,7 @@ fun BuildNavigation(
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -67,6 +67,7 @@ import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.MilitaryTech
import androidx.compose.material.icons.outlined.Photo
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material.icons.outlined.Sensors
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsInputAntenna
import androidx.compose.material.icons.outlined.SmartDisplay
@@ -656,6 +657,14 @@ fun ListContent(
route = Route.FollowPacks,
)
NavigationRow(
title = R.string.live_streams,
icon = Icons.Outlined.Sensors,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.LiveStreams,
)
NavigationRow(
title = R.string.longs,
icon = Icons.Outlined.SmartDisplay,
@@ -85,6 +85,8 @@ sealed class Route {
@Serializable object FollowPacks : Route()
@Serializable object LiveStreams : Route()
@Serializable object Longs : Route()
@Serializable object Articles : Route()
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.dal.Follow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.dal.LiveStreamsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal.LongsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
@@ -96,6 +97,7 @@ class AccountFeedContentStates(
val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache)
val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache)
val followPacksFeed = FeedContentState(FollowPacksFeedFilter(account), scope, LocalCache)
val liveStreamsFeed = FeedContentState(LiveStreamsFeedFilter(account), scope, LocalCache)
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
@@ -159,6 +161,7 @@ class AccountFeedContentStates(
productsFeed.updateFeedWith(newNotes)
shortsFeed.updateFeedWith(newNotes)
followPacksFeed.updateFeedWith(newNotes)
liveStreamsFeed.updateFeedWith(newNotes)
longsFeed.updateFeedWith(newNotes)
articlesFeed.updateFeedWith(newNotes)
@@ -203,6 +206,7 @@ class AccountFeedContentStates(
productsFeed.deleteFromFeed(newNotes)
shortsFeed.deleteFromFeed(newNotes)
followPacksFeed.deleteFromFeed(newNotes)
liveStreamsFeed.deleteFromFeed(newNotes)
longsFeed.deleteFromFeed(newNotes)
articlesFeed.deleteFromFeed(newNotes)
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LiveStreamsFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
ChannelCardCompose(
baseNote = item,
routeForLastRead = "LiveStreamsFeed",
modifier = Modifier.fillMaxWidth(),
forceEventKind = LiveActivitiesEvent.KIND,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssemblerSubscription
@Composable
fun LiveStreamsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
LiveStreamsScreen(
liveStreamsFeedContentState = accountViewModel.feedStates.liveStreamsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun LiveStreamsScreen(
liveStreamsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(liveStreamsFeedContentState)
WatchAccountForLiveStreamsScreen(liveStreamsFeedState = liveStreamsFeedContentState, accountViewModel = accountViewModel)
LiveStreamsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
LiveStreamsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.LiveStreams, accountViewModel) { route ->
if (route == Route.LiveStreams) {
liveStreamsFeedContentState.sendToTop()
} else {
nav.newStack(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(liveStreamsFeedContentState, true) {
SaveableFeedContentState(liveStreamsFeedContentState, scrollStateKey = ScrollStateKeys.LIVE_STREAMS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = liveStreamsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "LiveStreamsFeed",
onLoaded = { loaded ->
LiveStreamsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun WatchAccountForLiveStreamsScreen(
liveStreamsFeedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveLiveStreamsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
liveStreamsFeedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun LiveStreamsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultLiveStreamsFollowList
.collectAsStateWithLifecycle()
LiveStreamsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultLiveStreamsFollowList,
)
}
}
@Composable
private fun LiveStreamsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
class LiveStreamsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 50
fun followList(): TopFilter = account.settings.defaultLiveStreamsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) }
val allMessageNotes = LocalCache.liveChatChannels.map { _, channel -> channel.notes.filter { key, it -> it.event is LiveActivitiesEvent } }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams =
FilterByListParams.create(
followLists = account.liveLiveStreamsFollowLists.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent || noteEvent is MeetingSpaceEvent || noteEvent is MeetingRoomEvent) &&
filterParams.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> {
val topFilter = account.liveLiveStreamsFollowLists.value
val liveStreamsTopFilterAuthors =
when (topFilter) {
is AuthorsByOutboxTopNavFilter -> topFilter.authors
is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors
is AllFollowsByOutboxTopNavFilter -> topFilter.authors
is SingleCommunityTopNavFilter -> topFilter.authors
is AuthorsByProxyTopNavFilter -> topFilter.authors
is MutedAuthorsByProxyTopNavFilter -> topFilter.authors
is AllFollowsByProxyTopNavFilter -> topFilter.authors
else -> null
}
val followingKeySet =
liveStreamsTopFilterAuthors ?: account.kind3FollowList.flow.value.authors
val counter = ParticipantListBuilder()
val participantCounts =
items.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
val allParticipants =
items.associate { it to counter.countFollowsThatParticipateOn(it, null) }
return items
.sortedWith(
compareBy(
{ convertStatusToOrder(it.event) },
{ participantCounts[it] },
{ allParticipants[it] },
{
when (val e = it.event) {
is LiveActivitiesEvent -> e.starts() ?: it.createdAt()
is MeetingRoomEvent -> e.starts() ?: it.createdAt()
else -> it.createdAt()
}
},
{ it.idHex },
),
).reversed()
}
fun convertStatusToOrder(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Int {
if (event == null) return 0
return when (event) {
is LiveActivitiesEvent -> {
val url = event.streaming() ?: return 0
when (event.status()) {
StatusTag.STATUS.LIVE -> {
if (OnlineChecker.isCachedAndOffline(url)) 0 else 2
}
StatusTag.STATUS.PLANNED -> {
1
}
StatusTag.STATUS.ENDED -> {
0
}
else -> {
0
}
}
}
is MeetingRoomEvent -> {
when (event.status()) {
StatusTag.STATUS.LIVE -> 2
StatusTag.STATUS.PLANNED -> 1
StatusTag.STATUS.ENDED -> 0
else -> 0
}
}
is MeetingSpaceEvent -> {
when (event.status()) {
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.OPEN -> 2
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.PRIVATE -> 1
com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag.STATUS.CLOSED -> 0
else -> 0
}
}
else -> {
0
}
}
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class LiveStreamsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class LiveStreamsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<LiveStreamsQueryState>() {
val group =
listOf(
LiveStreamsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun LiveStreamsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
LiveStreamsFilterAssemblerSubscription(
accountViewModel.dataSources().liveStreams,
accountViewModel,
)
}
@Composable
fun LiveStreamsFilterAssemblerSubscription(
dataSource: LiveStreamsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
LiveStreamsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class LiveStreamsSubAssembler(
client: INostrClient,
allKeys: () -> Set<LiveStreamsQueryState>,
) : PerUserAndFollowListEoseManager<LiveStreamsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: LiveStreamsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
return makeLiveActivitiesFilter(feedSettings, since, key.feedStates.liveStreamsFeed.lastNoteCreatedAtIfFilled())
}
override fun user(key: LiveStreamsQueryState) = key.account.userProfile()
override fun list(key: LiveStreamsQueryState) = key.listName()
fun LiveStreamsQueryState.listNameFlow() = account.settings.defaultLiveStreamsFollowList
fun LiveStreamsQueryState.listName() = listNameFlow().value
fun LiveStreamsQueryState.followsPerRelayFlow() = account.liveLiveStreamsFollowListsPerRelay
fun LiveStreamsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: LiveStreamsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.account.scope.launch(Dispatchers.IO) {
key.feedStates.liveStreamsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
+1
View File
@@ -485,6 +485,7 @@
<string name="pictures">Pictures</string>
<string name="shorts">Shorts</string>
<string name="follow_packs">Follow Packs</string>
<string name="live_streams">Live Streams</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>