refactor: move podcast show/trailer, geocache log and visualizer settings to commonsUI

- BookmarkGroupItemOptions shares through rememberTextSharer instead of an
  ACTION_SEND intent.
- DocumentSelectSingle (expect/actual) picks one document by MIME type;
  the trailer composer uses it, and the geocache log uses
  GallerySelectSingle(imagesOnly = true).
- MediaUri.lastPathSegmentOrNull() replaces Uri.lastPathSegment in the
  composer view models.
- rfc2822Date (commons, tested against java.time's RFC_1123_DATE_TIME)
  replaces java.time in PodcastComposerMedia; the MediaMetadataRetriever
  probe moves to the app-side PodcastAudioProbe.
- The canvas-only FakeWaveformAnimation moves to commonsUI; the media3
  variant stays in the app.
- Moved (9 files): the audio visualizer settings, geocache find log,
  podcast show editor and podcast trailer screens with their view models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-29 20:04:24 +00:00
parent 297abd11e1
commit ab517f3af5
27 changed files with 363 additions and 174 deletions
@@ -20,29 +20,15 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.wavefront
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.components.wavefront.FakeWaveformAnimation
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -50,7 +36,6 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlin.math.sin
@Composable
fun FakeWaveformAnimation(
@@ -97,60 +82,3 @@ fun pollCurrentPosition(controller: Player) =
emit(controller.currentPosition)
}.flowOn(Dispatchers.IO)
.conflate()
@Preview
@Composable
fun FakeWaveformAnimationPreview() {
val state =
remember {
mutableFloatStateOf((500 % 1000.0f) / 1000.0f)
}
ThemeComparisonColumn {
FakeWaveformAnimation(state, 50, Modifier.width(200.dp))
}
}
@Composable
fun FakeWaveformAnimation(
waveformProgress: MutableFloatState,
barCount: Int = 50,
modifier: Modifier = Modifier,
) {
val barBrush =
Brush.linearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
start = Offset(0.2f * 128f, 0f),
end = Offset(0.2f * 128f + 128f, 0f),
)
Canvas(
modifier =
modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.padding(horizontal = 10.dp),
) {
val barWidth = 3.dp.toPx()
val barPadding = 2.dp.toPx()
val barRadius = 2.dp.toPx()
val totalBarWidth = barWidth + barPadding
val startX = (size.width - (barCount * totalBarWidth - barPadding)) / 2f
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount) * 2f * Math.PI.toFloat()
val wave = sin(waveformProgress.floatValue * 2f * Math.PI.toFloat() + phase)
val barFraction = ((wave + 1f) / 2f)
val barHeight = size.height * barFraction
val x = startX + i * totalBarWidth
val y = (size.height - barHeight) / 2f
drawRoundRect(
brush = barBrush,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barRadius, barRadius),
style = Fill,
)
}
}
}
@@ -121,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.favorites.FavoriteA
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.GeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.hunt.NewGeocacheHuntScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.log.LogGeocacheFindScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.hashtag.HashtagScreen
@@ -143,6 +144,8 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.nsites.NsitesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.pinnednotes.PinnedNotesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.PodcastsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.PollsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.polls.results.PollResultsScreen
@@ -158,6 +161,7 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.eventsync.Ev
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.nip43.RelayMembersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.vanish.VanishEventsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings.CallSettingsScreen
@@ -279,7 +283,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.GeocachesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create.NewGeocacheScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.detail.GeocacheDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.log.LogGeocacheFindScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen
@@ -306,9 +309,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.PicturesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.SendPaymentScreen
@@ -319,7 +320,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions.Active
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AudioVisualizerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -29,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
@@ -60,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.commons.ui.components.rememberTextSharer
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeEditDraftTo
@@ -171,7 +170,7 @@ fun BookmarkGroupItemOptionsMenu(
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboard.current
val actContext = LocalContext.current
val sharer = rememberTextSharer()
val scope = rememberCoroutineScope()
// Bookmark Management section
@@ -240,23 +239,7 @@ fun BookmarkGroupItemOptionsMenu(
}
}
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(Res.string.quick_action_share)) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
quickActionShareBrowserLinkStr,
)
}
val shareIntent =
Intent.createChooser(sendIntent, quickActionShareStr)
actContext.startActivity(shareIntent)
sharer.share(externalLinkForNote(note), quickActionShareBrowserLinkStr, quickActionShareStr)
onDismiss()
}
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.service.uploads.lastPathSegmentOrNull
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -176,7 +177,7 @@ class NewMusicTrackViewModel : ViewModel() {
audioMedia.value = MultiOrchestrator(persistentListOf(uri))
// Fall back to the last path segment of the content:// URI — Android picker URIs
// rarely expose a clean filename, but the segment is at least stable across recomposes.
pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
pickedAudioName.value = uri.uri.lastPathSegmentOrNull()?.substringAfterLast('/')
// Auto-fill title/artist/album/duration from the picked file's embedded metadata so
// the user doesn't have to retype what's already in the ID3 / mp4 / FLAC tags.
@@ -98,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.CoverImagePicker
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.UploadInProgressBanner
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.music.UploadPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.V4VSplitEditor
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import kotlinx.collections.immutable.persistentListOf
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.lastPathSegmentOrNull
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.PodcastComposerMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
@@ -154,11 +156,11 @@ class NewPodcastEpisodeViewModel : ViewModel() {
return
}
audioMedia.value = MultiOrchestrator(persistentListOf(uri))
pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
pickedAudioName.value = uri.uri.lastPathSegmentOrNull()?.substringAfterLast('/')
val appContext = context.applicationContext
viewModelScope.launch(Dispatchers.IO) {
val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch
val probed = PodcastAudioProbe.probeAudio(appContext, uri.uri) ?: return@launch
withContext(Dispatchers.Main.immediate) {
probed.durationSeconds?.let { durationSeconds.value = it.toString() }
if (title.value.isBlank()) probed.title?.let { title.value = it }
@@ -0,0 +1,51 @@
/*
* 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.authoring
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring.PodcastComposerMedia
object PodcastAudioProbe {
/**
* Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off
* the main thread). Returns null if the provider rejects it or the file isn't a real container.
*/
fun probeAudio(
context: Context,
uri: Uri,
): PodcastComposerMedia.ProbedAudio? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
PodcastComposerMedia.ProbedAudio(
durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } },
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null },
)
} catch (_: Exception) {
null
} finally {
retriever.release()
}
}
}
@@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.commons.service.uploads
import android.net.Uri
actual typealias MediaUri = Uri
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegment
@@ -0,0 +1,52 @@
/*
* 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.commons.podcasts
private val WEEKDAYS = arrayOf("Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed")
private val MONTHS = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
/**
* [epochSeconds] as an RFC 2822 date in GMT (`Tue, 24 Jun 2025 12:00:00 GMT`), the form
* Podcasting 2.0's `pubdate` and RSS use. Matches `java.time`'s `RFC_1123_DATE_TIME`, which
* does not zero-pad the day.
*/
fun rfc2822Date(epochSeconds: Long): String {
val days = epochSeconds.floorDiv(86_400L)
val secondOfDay = epochSeconds.mod(86_400L)
// Civil-from-days (Howard Hinnant), proleptic Gregorian.
val z = days + 719_468L
val era = z.floorDiv(146_097L)
val doe = z - era * 146_097L
val yoe = (doe - doe / 1_460L + doe / 36_524L - doe / 146_096L) / 365L
val doy = doe - (365L * yoe + yoe / 4L - yoe / 100L)
val mp = (5L * doy + 2L) / 153L
val day = doy - (153L * mp + 2L) / 5L + 1L
val month = if (mp < 10L) mp + 3L else mp - 9L
val year = yoe + era * 400L + if (month <= 2L) 1L else 0L
val weekday = WEEKDAYS[days.mod(7L).toInt()]
val hh = (secondOfDay / 3_600L).toString().padStart(2, '0')
val mm = (secondOfDay / 60L % 60L).toString().padStart(2, '0')
val ss = (secondOfDay % 60L).toString().padStart(2, '0')
return "$weekday, $day ${MONTHS[(month - 1L).toInt()]} $year $hh:$mm:$ss GMT"
}
@@ -29,3 +29,6 @@ expect abstract class MediaUri {
/** The address as a string (`content://…` on Android). */
abstract override fun toString(): String
}
/** The last path segment of this address, if it has one (on Android, `Uri.lastPathSegment`). */
expect fun MediaUri.lastPathSegmentOrNull(): String?
@@ -30,3 +30,11 @@ class StringMediaUri(
) : MediaUri() {
override fun toString(): String = value
}
actual fun MediaUri.lastPathSegmentOrNull(): String? =
toString()
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
.substringAfterLast('/')
.ifBlank { null }
@@ -30,3 +30,11 @@ class StringMediaUri(
) : MediaUri() {
override fun toString(): String = value
}
actual fun MediaUri.lastPathSegmentOrNull(): String? =
toString()
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
.substringAfterLast('/')
.ifBlank { null }
@@ -0,0 +1,44 @@
/*
* 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.commons.podcasts
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.test.Test
import kotlin.test.assertEquals
class Rfc2822DateTest {
private fun javaTime(epochSeconds: Long) = DateTimeFormatter.RFC_1123_DATE_TIME.format(Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.of("GMT")))
@Test
fun knownDate() {
assertEquals("Tue, 24 Jun 2025 12:00:00 GMT", rfc2822Date(1_750_766_400L))
}
@Test
fun matchesJavaTimeAcrossDates() {
val samples =
listOf(0L, 59L, 86_399L, 86_400L, 951_782_400L, 951_868_799L, 1_709_164_800L, 4_107_542_399L, 1_750_766_400L) +
(0 until 2_000).map { it * 2_629_743L + it * 7_919L }
samples.forEach { assertEquals(javaTime(it), rfc2822Date(it), "epoch $it") }
}
}
@@ -24,6 +24,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -69,3 +70,17 @@ actual fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit) {
LaunchFilePicker()
}
@Composable
actual fun DocumentSelectSingle(
mimeTypes: List<String>,
onPicked: (SelectedMedia?) -> Unit,
) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(mimeTypes.toTypedArray()) }
}
@@ -91,3 +91,10 @@ private fun FileSelectButton(
/** Opens the platform's document picker for audio and PDF files; reports an empty list on cancel. */
@Composable
expect fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit = {})
/** Opens the platform's document picker for one file of [mimeTypes]; reports null on cancel. */
@Composable
expect fun DocumentSelectSingle(
mimeTypes: List<String>,
onPicked: (SelectedMedia?) -> Unit,
)
@@ -0,0 +1,100 @@
/*
* 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.commons.ui.components.wavefront
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import kotlin.math.PI
import kotlin.math.sin
@Preview
@Composable
fun FakeWaveformAnimationPreview() {
val state =
remember {
mutableFloatStateOf((500 % 1000.0f) / 1000.0f)
}
ThemeComparisonColumn {
FakeWaveformAnimation(state, 50, Modifier.width(200.dp))
}
}
@Composable
fun FakeWaveformAnimation(
waveformProgress: MutableFloatState,
barCount: Int = 50,
modifier: Modifier = Modifier,
) {
val barBrush =
Brush.linearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
start = Offset(0.2f * 128f, 0f),
end = Offset(0.2f * 128f + 128f, 0f),
)
Canvas(
modifier =
modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.padding(horizontal = 10.dp),
) {
val barWidth = 3.dp.toPx()
val barPadding = 2.dp.toPx()
val barRadius = 2.dp.toPx()
val totalBarWidth = barWidth + barPadding
val startX = (size.width - (barCount * totalBarWidth - barPadding)) / 2f
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount) * 2f * PI.toFloat()
val wave = sin(waveformProgress.floatValue * 2f * PI.toFloat() + phase)
val barFraction = ((wave + 1f) / 2f)
val barHeight = size.height * barFraction
val x = startX + i * totalBarWidth
val y = (size.height - barHeight) / 2f
drawRoundRect(
brush = barBrush,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(barRadius, barRadius),
style = Fill,
)
}
}
}
@@ -18,11 +18,8 @@
* 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.geocaches.log
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
@@ -65,7 +62,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -87,6 +83,8 @@ import com.vitorpamplona.amethyst.commons.resources.geocache_log_scan_code
import com.vitorpamplona.amethyst.commons.resources.geocache_log_your_log
import com.vitorpamplona.amethyst.commons.resources.geocache_unnamed
import com.vitorpamplona.amethyst.commons.resources.route_log_geocache_find
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.rememberGeocachePalette
@@ -121,7 +119,7 @@ fun LogGeocacheFindScreen(
GeocachesFilterAssemblerSubscription(accountViewModel)
val address = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) }
val model: LogGeocacheFindViewModel = viewModel(key = "LogGeocacheFind-${address.toValue()}")
val model: LogGeocacheFindViewModel = rememberViewModel(key = "LogGeocacheFind-${address.toValue()}") { LogGeocacheFindViewModel() }
val scope = rememberCoroutineScope()
remember(address) {
@@ -132,12 +130,15 @@ fun LogGeocacheFindScreen(
val listing = model.listing()
var scanning by remember { mutableStateOf(false) }
val photoPicker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch { model.uploadImage(uri, null, accountViewModel.host.mediaUploader) }
var pickingPhoto by remember { mutableStateOf(false) }
if (pickingPhoto) {
GallerySelectSingle(imagesOnly = true) { media ->
pickingPhoto = false
if (media != null) {
scope.launch { model.uploadImage(media.uri, media.mimeType, accountViewModel.host.mediaUploader) }
}
}
}
Scaffold(
topBar = {
@@ -219,7 +220,7 @@ fun LogGeocacheFindScreen(
OutlinedButton(
enabled = !model.isUploading.value,
onClick = {
photoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
pickingPhoto = true
},
) {
if (model.isUploading.value) {
@@ -18,9 +18,8 @@
* 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.geocaches.log
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.geocaches.log
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -28,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -183,7 +183,7 @@ class LogGeocacheFindViewModel : ViewModel() {
}
suspend fun uploadImage(
uri: Uri,
uri: MediaUri,
mimeType: String?,
uploader: MediaUploader,
): Boolean {
@@ -18,7 +18,7 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -47,7 +47,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.podcast_edit_show
import com.vitorpamplona.amethyst.commons.resources.podcast_publishing_banner
@@ -74,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_show_type_serial
import com.vitorpamplona.amethyst.commons.resources.podcast_show_website_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
@@ -95,7 +95,7 @@ fun EditPodcastShowScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: EditPodcastShowViewModel = viewModel()
val vm: EditPodcastShowViewModel = rememberViewModel { EditPodcastShowViewModel() }
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
@@ -18,7 +18,7 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -18,11 +18,8 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -50,12 +47,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -72,6 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_upload_hint
import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_url_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.DocumentSelectSingle
import com.vitorpamplona.amethyst.commons.ui.components.rememberViewModel
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
@@ -87,7 +84,7 @@ fun NewPodcastTrailerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: NewPodcastTrailerViewModel = viewModel()
val vm: NewPodcastTrailerViewModel = rememberViewModel { NewPodcastTrailerViewModel() }
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
@@ -201,14 +198,6 @@ fun NewPodcastTrailerScreen(
}
}
// Audio or video file via OpenDocument.
// Audio or video file via the document picker.
@Composable
private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) {
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
)
LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*", "video/*")) }
}
private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) = DocumentSelectSingle(listOf("audio/*", "video/*"), onPicked)
@@ -18,7 +18,7 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.lastPathSegmentOrNull
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.persistentListOf
@@ -78,7 +79,7 @@ class NewPodcastTrailerViewModel : ViewModel() {
return
}
media.value = MultiOrchestrator(persistentListOf(uri))
pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/')
pickedName.value = uri.uri.lastPathSegmentOrNull()?.substringAfterLast('/')
pickedMimeType.value = uri.mimeType
}
@@ -149,7 +150,7 @@ class NewPodcastTrailerViewModel : ViewModel() {
_completionEvents.tryEmit(Unit)
} catch (t: Throwable) {
accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName)
accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t::class.simpleName ?: "")
} finally {
withContext(Dispatchers.Main.immediate) { isSending.value = false }
}
@@ -18,13 +18,11 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.podcasts.rfc2822Date
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
@@ -32,10 +30,9 @@ import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer).
@@ -97,34 +94,12 @@ object PodcastComposerMedia {
val title: String?,
)
/**
* Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off
* the main thread). Returns null if the provider rejects it or the file isn't a real container.
*/
fun probeAudio(
context: Context,
uri: Uri,
): ProbedAudio? {
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
ProbedAudio(
durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } },
title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null },
)
} catch (_: Exception) {
null
} finally {
retriever.release()
}
}
/** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */
fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT")))
fun rfc2822Now(): String = rfc2822Date(TimeUtils.now())
/** A fresh, stable `d` tag for a new addressable episode/trailer. */
fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}"
@OptIn(ExperimentalUuidApi::class)
fun generateDTag(prefix: String): String = "$prefix-${TimeUtils.now()}-${Uuid.random().toString().take(8)}"
/** Splits a comma-separated text field into a clean list (trimmed, no blanks). */
fun parseCsv(text: String): List<String> = text.split(',').map { it.trim() }.filter { it.isNotEmpty() }
@@ -18,7 +18,7 @@
* 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.authoring
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.podcasts.authoring
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -18,7 +18,7 @@
* 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.settings
package com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -64,11 +64,11 @@ import com.vitorpamplona.amethyst.commons.resources.audio_visualizer_settings
import com.vitorpamplona.amethyst.commons.resources.audio_visualizer_settings_description
import com.vitorpamplona.amethyst.commons.resources.audio_visualizer_static
import com.vitorpamplona.amethyst.commons.resources.audio_visualizer_waves
import com.vitorpamplona.amethyst.commons.ui.components.wavefront.FakeWaveformAnimation
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.wavefront.FakeWaveformAnimation
import kotlinx.coroutines.flow.Flow
@Composable
@@ -31,3 +31,12 @@ import kotlinx.collections.immutable.persistentListOf
actual fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit) {
LaunchedEffect(Unit) { onFilesSelected(persistentListOf()) }
}
/** No document picker wired yet: reports a cancel. */
@Composable
actual fun DocumentSelectSingle(
mimeTypes: List<String>,
onPicked: (SelectedMedia?) -> Unit,
) {
LaunchedEffect(Unit) { onPicked(null) }
}
@@ -31,3 +31,12 @@ import kotlinx.collections.immutable.persistentListOf
actual fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit) {
LaunchedEffect(Unit) { onFilesSelected(persistentListOf()) }
}
/** No document picker wired yet: reports a cancel. */
@Composable
actual fun DocumentSelectSingle(
mimeTypes: List<String>,
onPicked: (SelectedMedia?) -> Unit,
) {
LaunchedEffect(Unit) { onPicked(null) }
}