mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 00:37:41 +00:00
Merge pull request #3105 from vitorpamplona/claude/eager-davinci-WxjiM
Add dedicated podcast screen with episode list and inline player
This commit is contained in:
+66
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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.service.playback.composable.wavefront
|
||||
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val SYNTHETIC_WAVEFORM_SAMPLES = 96
|
||||
private const val TWO_PI = (Math.PI * 2).toFloat()
|
||||
|
||||
/**
|
||||
* Builds a stable, decorative waveform from a [seed] (usually an event id). ExoPlayer drives
|
||||
* actual playback progress — these bars only exist so an audio player has the familiar
|
||||
* "audio silhouette" shape rather than a flat strip when the source carries no `waveform` tag.
|
||||
* Shared by every audio renderer whose spec omits waveforms (music tracks, podcast episodes, …).
|
||||
*
|
||||
* Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter
|
||||
* envelope) is drawn from the seeded RNG before the per-bar loop, so two different seeds
|
||||
* produce visibly different waveforms — earlier versions used a fixed envelope + carrier that
|
||||
* only varied by per-bar noise, which made every track look like the same slow-fade sine with
|
||||
* minor wiggle.
|
||||
*/
|
||||
fun syntheticWaveformFor(seed: String): WaveformData {
|
||||
// Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide
|
||||
// (rare for hex addresses but cheap to defend against) still differ via the bit shuffle.
|
||||
val rng = Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong())
|
||||
|
||||
// Shape parameters drawn once per seed — these are what makes seed A look different
|
||||
// from seed B at a glance.
|
||||
val phaseOffset = rng.nextFloat() * TWO_PI
|
||||
val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.5–8 full cycles across the strip
|
||||
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36
|
||||
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50
|
||||
val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some fade in/out, others don't
|
||||
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.40
|
||||
|
||||
val bars =
|
||||
List(SYNTHETIC_WAVEFORM_SAMPLES) { index ->
|
||||
val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES
|
||||
// Optional gentle fade so some taper at the ends, others stay even.
|
||||
val envelope = 1f - envelopeStrength * (1f - sin(phase * Math.PI).toFloat())
|
||||
val carrier = sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight
|
||||
val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength
|
||||
((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f)
|
||||
}
|
||||
return WaveformData(bars)
|
||||
}
|
||||
+3
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFi
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
|
||||
@@ -133,6 +134,7 @@ class RelaySubscriptionsCoordinator(
|
||||
val musicPlaylists = MusicPlaylistsFilterAssembler(client)
|
||||
val podcastEpisodes = PodcastEpisodesFilterAssembler(client)
|
||||
val podcasts = PodcastsFilterAssembler(client)
|
||||
val onePodcast = OnePodcastFilterAssembler(client)
|
||||
val softwareApps = SoftwareAppsFilterAssembler(client)
|
||||
val badges = BadgesFilterAssembler(client)
|
||||
val profileBadges = ProfileBadgesFilterAssembler(client)
|
||||
@@ -179,6 +181,7 @@ class RelaySubscriptionsCoordinator(
|
||||
musicPlaylists,
|
||||
podcastEpisodes,
|
||||
podcasts,
|
||||
onePodcast,
|
||||
softwareApps,
|
||||
badges,
|
||||
profileBadges,
|
||||
|
||||
@@ -148,6 +148,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen
|
||||
@@ -297,6 +298,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.MusicPlaylists> { MusicPlaylistsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PodcastEpisodes> { PodcastEpisodesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Podcasts> { PodcastsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Podcast> { PodcastScreen(it.pubkey, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.NewMusicTrack> { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEndArgs<Route.AddToMusicPlaylist> { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) }
|
||||
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
|
||||
|
||||
@@ -162,6 +162,10 @@ sealed class Route {
|
||||
|
||||
@Serializable object Podcasts : Route()
|
||||
|
||||
@Serializable data class Podcast(
|
||||
val pubkey: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class NewMusicTrack(
|
||||
val dTag: String? = null,
|
||||
|
||||
@@ -62,8 +62,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -438,46 +438,6 @@ private fun TopicChip(
|
||||
)
|
||||
}
|
||||
|
||||
private const val SYNTHETIC_WAVEFORM_SAMPLES = 96
|
||||
private const val TWO_PI = (Math.PI * 2).toFloat()
|
||||
|
||||
/**
|
||||
* Builds a stable, decorative waveform from the track id. ExoPlayer drives actual playback
|
||||
* progress — these bars only exist so the audio player has the familiar "music silhouette"
|
||||
* shape rather than a flat strip when the spec has no `waveform` tag.
|
||||
*
|
||||
* Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter
|
||||
* envelope) is drawn from the seeded RNG before the per-bar loop, so two different track ids
|
||||
* produce visibly different waveforms — earlier versions used a fixed envelope + carrier that
|
||||
* only varied by per-bar noise, which made every track look like the same slow-fade sine with
|
||||
* minor wiggle.
|
||||
*/
|
||||
private fun syntheticWaveformFor(seed: String): WaveformData {
|
||||
// Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide
|
||||
// (rare for hex addresses but cheap to defend against) still differ via the bit shuffle.
|
||||
val rng = kotlin.random.Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong())
|
||||
|
||||
// Shape parameters drawn once per track — these are what makes track A look different
|
||||
// from track B at a glance.
|
||||
val phaseOffset = rng.nextFloat() * TWO_PI
|
||||
val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.5–8 full cycles across the strip
|
||||
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.18–0.36
|
||||
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.28–0.50
|
||||
val envelopeStrength = rng.nextFloat() * 0.45f // 0.0–0.45; some tracks fade in/out, others don't
|
||||
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.18–0.40
|
||||
|
||||
val bars =
|
||||
List(SYNTHETIC_WAVEFORM_SAMPLES) { index ->
|
||||
val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES
|
||||
// Optional gentle fade so some tracks taper at the ends, others stay even.
|
||||
val envelope = 1f - envelopeStrength * (1f - kotlin.math.sin(phase * Math.PI).toFloat())
|
||||
val carrier = kotlin.math.sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight
|
||||
val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength
|
||||
((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f)
|
||||
}
|
||||
return WaveformData(bars)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// @Preview composables. These wire a constructed MusicTrackEvent through
|
||||
// LocalCache so the renderer pulls the real Note path it would in production.
|
||||
|
||||
@@ -22,10 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -33,7 +31,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,10 +39,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -53,12 +46,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
|
||||
// NIP-F4 doesn't carry a waveform tag, so every episode renders with the same flat baseline.
|
||||
// Hoist to a top-level constant so we share one List<Float>+WaveformData across the whole
|
||||
// feed instead of allocating a fresh 96-element list per visible card.
|
||||
private const val WAVEFORM_SAMPLES = 96
|
||||
private val FLAT_WAVEFORM = WaveformData(List(WAVEFORM_SAMPLES) { 0.4f })
|
||||
|
||||
// Bottom-rounded border on the audio player so it visually butts up against the cover's
|
||||
// top-rounded corners as one card. Constant — keep out of recomposition.
|
||||
private val PLAYER_BORDER_MODIFIER =
|
||||
@@ -88,39 +75,14 @@ fun RenderPodcastEpisode(
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
PodcastCoverCard(image, note, accountViewModel)
|
||||
firstAudio?.let { audio ->
|
||||
val callbackUri = remember(noteEvent) { note.toNostrUri() }
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(80.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GetMediaItem(
|
||||
videoUri = audio.url,
|
||||
title = title,
|
||||
artworkUri = image,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
callbackUri = callbackUri,
|
||||
mimeType = audio.mediaType,
|
||||
aspectRatio = null,
|
||||
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
|
||||
keepPlaying = false,
|
||||
waveformData = FLAT_WAVEFORM,
|
||||
) { mediaItem ->
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
muted = false,
|
||||
) { controller ->
|
||||
PauseControllerWhenInBackground(controller)
|
||||
RenderVoicePlayer(
|
||||
mediaItem = mediaItem,
|
||||
controllerState = controller,
|
||||
waveform = FLAT_WAVEFORM,
|
||||
borderModifier = PLAYER_BORDER_MODIFIER,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
PodcastEpisodeAudioPlayer(
|
||||
audio = audio,
|
||||
note = note,
|
||||
title = title,
|
||||
image = image,
|
||||
borderModifier = PLAYER_BORDER_MODIFIER,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -142,12 +104,18 @@ fun RenderPodcastEpisode(
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
val descriptionTags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = descriptionTags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex + "-description",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag
|
||||
|
||||
// The voice player's internal controls are laid out for 100.dp; 80.dp is the tightest height
|
||||
// that still fits the play button without clipping it. Shared so the feed card and the
|
||||
// per-podcast list row stay pixel-identical.
|
||||
private val PLAYER_HEIGHT_MODIFIER = Modifier.fillMaxWidth().height(80.dp)
|
||||
|
||||
/**
|
||||
* The inline audio strip for a NIP-F4 episode (kind 54): one [AudioTag] played through the
|
||||
* shared media-controller stack. [borderModifier] shapes the strip — bottom-rounded when it
|
||||
* butts up under a cover image, fully rounded when it stands alone in a list.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastEpisodeAudioPlayer(
|
||||
audio: AudioTag,
|
||||
note: Note,
|
||||
title: String?,
|
||||
image: String?,
|
||||
borderModifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val callbackUri = remember(note) { note.toNostrUri() }
|
||||
// Use a real waveform when the episode ships one in its IMeta; NIP-F4 usually doesn't, so
|
||||
// fall back to a decorative waveform seeded by the note id — same approach as music tracks,
|
||||
// a per-episode silhouette instead of a flat strip.
|
||||
val waveform =
|
||||
remember(note) {
|
||||
note.event
|
||||
?.getAudioMetaWithWaveform()
|
||||
?.waveform
|
||||
?.let { WaveformData(it) }
|
||||
?: syntheticWaveformFor(note.idHex)
|
||||
}
|
||||
|
||||
Row(
|
||||
PLAYER_HEIGHT_MODIFIER,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GetMediaItem(
|
||||
videoUri = audio.url,
|
||||
title = title,
|
||||
artworkUri = image,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
callbackUri = callbackUri,
|
||||
mimeType = audio.mediaType,
|
||||
aspectRatio = null,
|
||||
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(audio.url),
|
||||
keepPlaying = false,
|
||||
waveformData = waveform,
|
||||
) { mediaItem ->
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
muted = false,
|
||||
) { controller ->
|
||||
PauseControllerWhenInBackground(controller)
|
||||
RenderVoicePlayer(
|
||||
mediaItem = mediaItem,
|
||||
controllerState = controller,
|
||||
waveform = waveform,
|
||||
borderModifier = borderModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,25 +20,36 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
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.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
@@ -49,10 +60,10 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
fun RenderPodcastMetadata(
|
||||
note: Note,
|
||||
makeItShort: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") canPreview: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") backgroundColor: MutableState<Color>,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
@Suppress("UNUSED_PARAMETER") nav: INav,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? PodcastMetadataEvent ?: return
|
||||
|
||||
@@ -60,8 +71,15 @@ fun RenderPodcastMetadata(
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
val description = remember(noteEvent) { noteEvent.description() }
|
||||
val websites = remember(noteEvent) { noteEvent.websites() }
|
||||
// Each podcast is its own keypair, so the author pubkey IS the podcast id used to open
|
||||
// its dedicated screen with the full episode list.
|
||||
val podcastPubkey = remember(noteEvent) { noteEvent.pubKey }
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
Column(
|
||||
MaterialTheme.colorScheme.replyModifier.clickable {
|
||||
nav.nav(Route.Podcast(podcastPubkey))
|
||||
},
|
||||
) {
|
||||
PodcastCoverCard(image, note, accountViewModel)
|
||||
|
||||
Column(
|
||||
@@ -83,12 +101,18 @@ fun RenderPodcastMetadata(
|
||||
}
|
||||
|
||||
description?.takeIf { !makeItShort }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = note.idHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,6 +132,32 @@ fun RenderPodcastMetadata(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Affordance that this card opens a full show page with every episode.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = Size5dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Podcasts,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.podcast_view_episodes),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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.podcasts
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
|
||||
private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp))
|
||||
|
||||
/**
|
||||
* One episode as a compact "show list" row inside a podcast's screen: publish date, title,
|
||||
* short description, and an inline audio player so the episode plays without leaving the list.
|
||||
* The show artwork is the same for every episode, so it's omitted here — the header carries it.
|
||||
*/
|
||||
@Composable
|
||||
fun PodcastEpisodeListItem(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = note.event as? PodcastEpisodeEvent ?: return
|
||||
|
||||
val title = remember(noteEvent) { noteEvent.title() }
|
||||
val description = remember(noteEvent) { noteEvent.description() }
|
||||
val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() }
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
|
||||
val context = LocalContext.current
|
||||
val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { routeFor(note, accountViewModel.account)?.let { nav.nav(it) } }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
dateStr.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
firstAudio?.let { audio ->
|
||||
PodcastEpisodeAudioPlayer(
|
||||
audio = audio,
|
||||
note = note,
|
||||
title = title,
|
||||
image = image,
|
||||
borderModifier = PLAYER_SHAPE,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.podcasts
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
/**
|
||||
* Hero header for a single podcast screen: large cover art, title, websites and the show
|
||||
* description (rich text + translation, same as a profile's About), followed by the
|
||||
* "Episodes (N)" section divider that the episode rows hang under.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun PodcastHeader(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
episodeCount: Int?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val title = remember(metadataEvent) { metadataEvent?.title() }
|
||||
val image = remember(metadataEvent) { metadataEvent?.image() }
|
||||
val description = remember(metadataEvent) { metadataEvent?.description() }
|
||||
val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() }
|
||||
val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
PodcastCoverCard(image, metadataNote, accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
if (websites.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
websites.forEach { website ->
|
||||
Text(
|
||||
text = website,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
description?.let {
|
||||
val background = remember { mutableStateOf(Color.Transparent) }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = tags,
|
||||
backgroundColor = background,
|
||||
id = metadataNote.idHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Only render once episodes have actually loaded — avoids flashing "0 episodes"
|
||||
// under the cover while the relay request is still in flight.
|
||||
episodeCount?.let { count ->
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.podcast_episode_count, count, count),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.podcasts
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.feeds.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
@Composable
|
||||
fun PodcastScreen(
|
||||
pubkey: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return
|
||||
val metadataNote =
|
||||
remember(pubkey) {
|
||||
LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey))
|
||||
}
|
||||
|
||||
val feedViewModel: OnePodcastFeedViewModel =
|
||||
viewModel(
|
||||
key = pubkey + "OnePodcastFeedViewModel",
|
||||
factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account),
|
||||
)
|
||||
|
||||
PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PodcastScreen(
|
||||
podcast: User,
|
||||
metadataNote: Note,
|
||||
feedViewModel: OnePodcastFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(feedViewModel)
|
||||
// Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by
|
||||
// this podcast's key from its outbox relays.
|
||||
OnePodcastFilterAssemblerSubscription(podcast, accountViewModel)
|
||||
|
||||
val metadataEvent by observeNoteEvent<PodcastMetadataEvent>(metadataNote, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
TopBarExtensibleWithBackButton(
|
||||
title = {
|
||||
Text(
|
||||
text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableBox(feedViewModel, true) {
|
||||
SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState ->
|
||||
PodcastScreenBody(
|
||||
metadataNote = metadataNote,
|
||||
metadataEvent = metadataEvent,
|
||||
feedViewModel = feedViewModel,
|
||||
listState = listState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodcastScreenBody(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
feedViewModel: OnePodcastFeedViewModel,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val feedState by feedViewModel.feedState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
when (val state = feedState) {
|
||||
is FeedState.Loaded ->
|
||||
PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav)
|
||||
|
||||
is FeedState.Empty ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
StatusText(stringRes(R.string.podcast_no_episodes))
|
||||
}
|
||||
|
||||
is FeedState.FeedError ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
FeedError(state.errorMessage) { feedViewModel.invalidateData() }
|
||||
}
|
||||
|
||||
is FeedState.Loading ->
|
||||
PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodcastEpisodesList(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
loaded: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
) {
|
||||
item("header") {
|
||||
PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, _ -> "episode" },
|
||||
) { index, episode ->
|
||||
PodcastEpisodeListItem(episode, accountViewModel, nav)
|
||||
|
||||
if (index < items.list.lastIndex) {
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodcastHeaderWithStatus(
|
||||
metadataNote: Note,
|
||||
metadataEvent: PodcastMetadataEvent?,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
status: @Composable () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
) {
|
||||
item("header") {
|
||||
PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav)
|
||||
}
|
||||
item("status") { status() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusText(text: String) {
|
||||
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.podcasts.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
|
||||
/**
|
||||
* Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all
|
||||
* episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events,
|
||||
* so they live in `LocalCache.notes`. Most-recent-first via [DefaultFeedOrder].
|
||||
*/
|
||||
class OnePodcastEpisodesFeedFilter(
|
||||
val podcastPubkey: HexKey,
|
||||
val account: Account,
|
||||
val cache: LocalCache,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "podcast-" + podcastPubkey
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes =
|
||||
cache.notes.filterIntoSet { _, it ->
|
||||
acceptableEvent(it)
|
||||
}
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { acceptableEvent(it) }
|
||||
|
||||
private fun acceptableEvent(note: Note): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is PodcastEpisodeEvent &&
|
||||
noteEvent.pubKey == podcastPubkey &&
|
||||
!note.isHiddenFor(account.hiddenUsers.flow.value) &&
|
||||
account.isAcceptable(note)
|
||||
}
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.podcasts.dal
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Stable
|
||||
class OnePodcastFeedViewModel(
|
||||
val podcastPubkey: HexKey,
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(
|
||||
OnePodcastEpisodesFeedFilter(podcastPubkey, account, LocalCache),
|
||||
) {
|
||||
class Factory(
|
||||
val podcastPubkey: HexKey,
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = OnePodcastFeedViewModel(podcastPubkey, account) as T
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
|
||||
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
|
||||
|
||||
// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every
|
||||
// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches
|
||||
// both kinds from that one author.
|
||||
private val OnePodcastKinds =
|
||||
listOf(
|
||||
PodcastMetadataEvent.KIND,
|
||||
PodcastEpisodeEvent.KIND,
|
||||
)
|
||||
|
||||
fun filterOnePodcast(
|
||||
user: User,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// Outbox relays for the podcast key are where the show publishes. Fall back to any relay
|
||||
// we've seen the key on plus stored hints when no NIP-65 list is known.
|
||||
val relays =
|
||||
user.outboxRelays()?.ifEmpty { null }
|
||||
?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex))
|
||||
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = OnePodcastKinds,
|
||||
authors = listOf(user.pubkeyHex),
|
||||
limit = 500,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
|
||||
class OnePodcastFeedSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<OnePodcastQueryState>,
|
||||
) : PerUserEoseManager<OnePodcastQueryState>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
key: OnePodcastQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> = filterOnePodcast(user(key), since)
|
||||
|
||||
override fun user(key: OnePodcastQueryState) = key.user
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.podcasts.datasource
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// Keyed by the podcast's own pubkey (each podcast is its own keypair per NIP-F4). Multiple
|
||||
// screens can subscribe to the same podcast and share the single per-user subscription.
|
||||
class OnePodcastQueryState(
|
||||
val user: User,
|
||||
)
|
||||
|
||||
class OnePodcastFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<OnePodcastQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
OnePodcastFeedSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
override fun destroy() = group.forEach { it.destroy() }
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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.podcasts.datasource
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun OnePodcastFilterAssemblerSubscription(
|
||||
podcast: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// Different screens get their own state even when tracking the same podcast key.
|
||||
val state =
|
||||
remember(podcast) {
|
||||
OnePodcastQueryState(podcast)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().onePodcast)
|
||||
}
|
||||
@@ -766,6 +766,13 @@
|
||||
<string name="route_music_playlists">Playlists</string>
|
||||
<string name="route_podcast_episodes">Episodes</string>
|
||||
<string name="route_podcasts">Podcasts</string>
|
||||
<string name="podcast_view_episodes">View episodes</string>
|
||||
<string name="podcast_episodes_section">Episodes</string>
|
||||
<string name="podcast_no_episodes">No episodes found yet</string>
|
||||
<plurals name="podcast_episode_count">
|
||||
<item quantity="one">%1$d episode</item>
|
||||
<item quantity="other">%1$d episodes</item>
|
||||
</plurals>
|
||||
<string name="new_music_track">New music track</string>
|
||||
<string name="new_music_playlist">New playlist</string>
|
||||
<string name="music_track_title_label">Title</string>
|
||||
|
||||
Reference in New Issue
Block a user