mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+1
-73
@@ -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
|
||||
|
||||
+3
-20
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+4
-2
@@ -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 }
|
||||
|
||||
+51
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+52
@@ -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"
|
||||
}
|
||||
+3
@@ -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?
|
||||
|
||||
+8
@@ -30,3 +30,11 @@ class StringMediaUri(
|
||||
) : MediaUri() {
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
actual fun MediaUri.lastPathSegmentOrNull(): String? =
|
||||
toString()
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.trimEnd('/')
|
||||
.substringAfterLast('/')
|
||||
.ifBlank { null }
|
||||
|
||||
+8
@@ -30,3 +30,11 @@ class StringMediaUri(
|
||||
) : MediaUri() {
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
actual fun MediaUri.lastPathSegmentOrNull(): String? =
|
||||
toString()
|
||||
.substringBefore('?')
|
||||
.substringBefore('#')
|
||||
.trimEnd('/')
|
||||
.substringAfterLast('/')
|
||||
.ifBlank { null }
|
||||
|
||||
+44
@@ -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") }
|
||||
}
|
||||
}
|
||||
+15
@@ -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()) }
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
)
|
||||
|
||||
+100
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-11
@@ -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) {
|
||||
+3
-3
@@ -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 {
|
||||
+3
-3
@@ -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) }
|
||||
|
||||
+1
-1
@@ -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
|
||||
+6
-17
@@ -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)
|
||||
+4
-3
@@ -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 }
|
||||
}
|
||||
+8
-33
@@ -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() }
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+9
@@ -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) }
|
||||
}
|
||||
|
||||
+9
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user