From 973c2eeff7abc30a1f51837c39b645d51ae22c9b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 00:30:34 +0000 Subject: [PATCH] feat(music): add Music Track (kind 36787) and Music Playlist (kind 34139) Adds quartz support for two new addressable Nostr event kinds, modeled after the NIP-88 poll structure, plus modern Compose renderers wired into both the feed (NoteCompose) and the thread/master view (ThreadFeedView). Quartz: - MusicTrackEvent (36787): title/artist/url and optional album, track_number, released, duration, format, bitrate, sample_rate, language, explicit, image, video. Each field is a dedicated *Tag class with parse/assemble, plus a TagArrayBuilder DSL and a typed build() factory. Auto-emits the "t music" hashtag and an NIP-31 alt description. - MusicPlaylistEvent (34139): title, image, description, ordered "a" track references to MusicTrackEvent, plus public/private/collaborative flags. - Both registered in EventFactory so LocalCache materializes them as typed events instead of generic Event. Amethyst UI: - MusicTrack.kt: square cover with overlaid play affordance, large title, artist row with note icon, meta row (album/track #/release/duration/explicit badge), embedded VideoView for audio (or video URL when present) with cover thumbnail, lyric/credit content via TranslatableRichTextViewer, and topic chips for extra t tags. - MusicPlaylist.kt: cover with track-count badge, title, count + collaborative /private chips, descriptions, and an ordered list of tracks resolved via LoadAddressableNote (clickable, falls back to "loading"/"unknown track" for missing references). Capped at 25 with a "+N more tracks" footer. - Track-count strings use for correct CLDR pluralization. - Wired into NoteCompose `when` dispatch and ThreadFeedView header dispatch. --- .../amethyst/ui/note/NoteCompose.kt | 12 + .../amethyst/ui/note/types/MusicPlaylist.kt | 453 ++++++++++++++++++ .../amethyst/ui/note/types/MusicTrack.kt | 373 ++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 8 + amethyst/src/main/res/values/strings.xml | 13 + .../music/playlist/MusicPlaylistEvent.kt | 116 +++++ .../music/playlist/TagArrayBuilderExt.kt | 45 ++ .../music/playlist/tags/DescriptionTag.kt | 39 ++ .../music/playlist/tags/ImageTag.kt | 39 ++ .../music/playlist/tags/TitleTag.kt | 39 ++ .../music/playlist/tags/VisibilityTag.kt | 72 +++ .../music/track/MusicTrackEvent.kt | 135 ++++++ .../music/track/TagArrayBuilderExt.kt | 65 +++ .../experimental/music/track/tags/AlbumTag.kt | 39 ++ .../music/track/tags/ArtistTag.kt | 39 ++ .../music/track/tags/BitrateTag.kt | 39 ++ .../music/track/tags/DurationTag.kt | 39 ++ .../music/track/tags/ExplicitTag.kt | 40 ++ .../music/track/tags/FormatTag.kt | 39 ++ .../experimental/music/track/tags/ImageTag.kt | 39 ++ .../music/track/tags/LanguageTag.kt | 39 ++ .../music/track/tags/ReleasedTag.kt | 39 ++ .../music/track/tags/SampleRateTag.kt | 39 ++ .../experimental/music/track/tags/TitleTag.kt | 39 ++ .../music/track/tags/TrackNumberTag.kt | 39 ++ .../experimental/music/track/tags/UrlTag.kt | 39 ++ .../music/track/tags/VideoUrlTag.kt | 39 ++ .../quartz/utils/EventFactory.kt | 4 + 28 files changed, 1960 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8ee29194a5..a7da9decbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -148,6 +148,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent @@ -215,6 +217,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -918,6 +922,14 @@ private fun RenderNoteRow( RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav) } + is MusicTrackEvent -> { + RenderMusicTrack(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is MusicPlaylistEvent -> { + RenderMusicPlaylist(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt new file mode 100644 index 0000000000..ae829eddc9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicPlaylist.kt @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent + +private val PLAYLIST_COVER_ASPECT_RATIO = 1f +private const val MAX_PREVIEW_TRACKS = 25 + +@Composable +fun RenderMusicPlaylist( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? MusicPlaylistEvent ?: return + + MusicPlaylistHeader( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun MusicPlaylistHeader( + noteEvent: MusicPlaylistEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val image = remember(noteEvent) { noteEvent.image() } + val shortDescription = remember(noteEvent) { noteEvent.description() } + val longDescription = remember(noteEvent) { noteEvent.content.ifBlank { null } } + val trackAddresses = remember(noteEvent) { noteEvent.trackAddresses() } + val isCollaborative = remember(noteEvent) { noteEvent.isCollaborative() } + val isPrivate = remember(noteEvent) { noteEvent.isPrivate() } + + Column(MaterialTheme.colorScheme.replyModifier) { + MusicPlaylistCover(image, note, trackAddresses.size, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + val trackCount = trackAddresses.size + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count, trackCount, trackCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + if (isCollaborative) { + PlaylistTag(text = stringRes(R.string.music_playlist_collaborative)) + } + if (isPrivate) { + PlaylistTag(text = stringRes(R.string.music_playlist_private)) + } + } + + shortDescription?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + longDescription?.takeIf { !makeItShort }?.let { + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + val callbackUri = remember(note) { note.toNostrUri() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (trackAddresses.isNotEmpty()) { + Spacer(Modifier.padding(top = 4.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + trackAddresses + .take(MAX_PREVIEW_TRACKS) + .forEachIndexed { index, address -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + ) + } + LoadAddressableNote(address, accountViewModel) { trackNote -> + if (trackNote != null) { + PlaylistTrackRow( + position = index + 1, + trackNote = trackNote, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MissingPlaylistTrackRow(position = index + 1) + } + } + } + + if (trackAddresses.size > MAX_PREVIEW_TRACKS) { + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + val remaining = trackAddresses.size - MAX_PREVIEW_TRACKS + Text( + text = pluralStringResource(R.plurals.music_playlist_more_tracks, remaining, remaining), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + ) + } + } + } + } + } +} + +@Composable +private fun MusicPlaylistCover( + image: String?, + note: Note, + trackCount: Int, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(PLAYLIST_COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(12.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.55f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.PlaylistAdd, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = pluralStringResource(R.plurals.music_playlist_track_count, trackCount, trackCount), + style = MaterialTheme.typography.bodySmall, + color = Color.White, + fontWeight = FontWeight.SemiBold, + ) + } + } + } +} + +@Composable +private fun PlaylistTrackRow( + position: Int, + trackNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val trackEvent = trackNote.event as? MusicTrackEvent + val title = trackEvent?.title() ?: stringRes(R.string.music_playlist_unknown_track) + val artist = trackEvent?.artist() + val duration = trackEvent?.duration() + val cover = trackEvent?.image() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Note(trackNote.idHex)) } + .padding(vertical = 8.dp), + ) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .width(28.dp) + .padding(end = 4.dp), + ) + + if (cover != null) { + MyAsyncImage( + imageUrl = cover, + contentDescription = null, + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + loadedImageModifier = Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)), + accountViewModel = accountViewModel, + onLoadingBackground = { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + ) + }, + onError = { TrackCoverPlaceholder() }, + ) + } else { + TrackCoverPlaceholder() + } + + Spacer(Modifier.padding(start = 10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + artist?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + duration?.let { + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = formatTrackDuration(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } + } +} + +@Composable +private fun MissingPlaylistTrackRow(position: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) { + Text( + text = position.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .width(28.dp) + .padding(end = 4.dp), + ) + TrackCoverPlaceholder() + Spacer(Modifier.padding(start = 10.dp)) + Text( + text = stringRes(R.string.music_playlist_loading_track), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun TrackCoverPlaceholder() { + Box( + modifier = + Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun PlaylistTag(text: String) { + Spacer(Modifier.padding(start = 8.dp)) + Box( + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } +} + +private fun formatTrackDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt new file mode 100644 index 0000000000..f8c89445e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/MusicTrack.kt @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader +import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font10SP +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import kotlinx.collections.immutable.toImmutableList + +private val COVER_ASPECT_RATIO = 1f + +@Composable +fun RenderMusicTrack( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? MusicTrackEvent ?: return + + MusicTrackHeader( + noteEvent = noteEvent, + note = note, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MusicTrackHeader( + noteEvent: MusicTrackEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val title = remember(noteEvent) { noteEvent.title() } + val artist = remember(noteEvent) { noteEvent.artist() } + val url = remember(noteEvent) { noteEvent.url() } + val videoUrl = remember(noteEvent) { noteEvent.videoUrl() } + val image = remember(noteEvent) { noteEvent.image() } + val album = remember(noteEvent) { noteEvent.album() } + val trackNumber = remember(noteEvent) { noteEvent.trackNumber() } + val released = remember(noteEvent) { noteEvent.released() } + val duration = remember(noteEvent) { noteEvent.duration() } + val format = remember(noteEvent) { noteEvent.format() } + val isExplicit = remember(noteEvent) { noteEvent.isExplicit() } + val description = remember(noteEvent) { noteEvent.content.ifBlank { null } } + val topics = + remember(noteEvent) { + noteEvent.tags + .mapNotNull { if (it.size > 1 && it[0] == "t" && it[1] != MusicTrackEvent.GENRE_TAG) it[1] else null } + .distinct() + .take(4) + .toImmutableList() + } + val playableUri = videoUrl ?: url + val mimeType = + remember(format, videoUrl) { + when { + videoUrl != null -> "video/${format ?: "mp4"}" + format != null -> "audio/$format" + else -> null + } + } + + Column(MaterialTheme.colorScheme.replyModifier) { + MusicTrackCover(image, note, accountViewModel) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + artist?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.padding(start = 6.dp)) + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + + if (album != null || released != null || duration != null || isExplicit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + album?.let { + val trackPart = trackNumber?.let { n -> " · #$n" } ?: "" + MetaText(text = it + trackPart) + } + if (album != null && (released != null || duration != null || isExplicit)) MetaSeparator() + released?.let { MetaText(text = it) } + if (released != null && (duration != null || isExplicit)) MetaSeparator() + duration?.let { MetaText(text = formatDuration(it)) } + if (duration != null && isExplicit) MetaSeparator() + if (isExplicit) ExplicitBadge() + } + } + + if (playableUri != null) { + Spacer(Modifier.padding(top = 4.dp)) + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))) { + if (image != null) { + LoadThumbAndThenVideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + thumbUri = image, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } else { + VideoView( + videoUri = playableUri, + mimeType = mimeType, + title = title, + authorName = note.author?.toBestDisplayName(), + roundedCorner = true, + contentScale = ContentScale.FillWidth, + nostrUriCallback = "nostr:${note.toNEvent()}", + accountViewModel = accountViewModel, + ) + } + } + } + + description?.takeIf { !makeItShort }?.let { + Spacer(Modifier.padding(top = 4.dp)) + val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } + val callbackUri = remember(note) { note.toNostrUri() } + + TranslatableRichTextViewer( + content = it, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (noteEvent.hasHashtags()) { + Row(Modifier.fillMaxWidth()) { + DisplayUncitedHashtags(noteEvent, it, callbackUri, accountViewModel, nav) + } + } + } + + if (topics.isNotEmpty()) { + Spacer(Modifier.padding(top = 4.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Size5dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + topics.forEach { TopicChip(it) } + } + } + } + } +} + +@Composable +private fun MusicTrackCover( + image: String?, + note: Note, + accountViewModel: AccountViewModel, +) { + val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) + val imageModifier = + Modifier + .fillMaxWidth() + .aspectRatio(COVER_ASPECT_RATIO) + .clip(imageShape) + + Box(imageModifier) { + if (image != null) { + MyAsyncImage( + imageUrl = image, + contentDescription = stringRes(R.string.preview_card_image_for, image), + contentScale = ContentScale.Crop, + mainImageModifier = Modifier.fillMaxSize(), + loadedImageModifier = imageModifier, + accountViewModel = accountViewModel, + onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) }, + onError = { DefaultImageHeader(note, accountViewModel, imageModifier) }, + ) + } else { + DefaultImageHeader(note, accountViewModel, imageModifier) + } + + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(12.dp) + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(32.dp), + ) + } + } +} + +@Composable +private fun MetaText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun MetaSeparator() { + Text( + text = " · ", + color = MaterialTheme.colorScheme.grayText, + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun ExplicitBadge() { + Text( + text = "E", + style = MaterialTheme.typography.labelSmall, + fontSize = Font10SP, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) +} + +@Composable +private fun TopicChip(topic: String) { + Text( + text = "#$topic", + style = MaterialTheme.typography.labelSmall, + fontSize = Font10SP, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +private fun formatDuration(seconds: Int): String { + val minutes = seconds / 60 + val secs = seconds % 60 + return "%d:%02d".format(minutes, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 9ea627a300..a5c7ecc0b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -168,6 +168,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist +import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent @@ -228,6 +230,8 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease @@ -638,6 +642,10 @@ private fun FullBleedNoteCompose( AudioTrackHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) } else if (noteEvent is AudioHeaderEvent) { AudioHeader(noteEvent, baseNote, ContentScale.FillWidth, accountViewModel, nav) + } else if (noteEvent is MusicTrackEvent) { + RenderMusicTrack(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is MusicPlaylistEvent) { + RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e9897200d3..fcc19c9623 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1505,6 +1505,19 @@ %1$d min read + + %1$d track + %1$d tracks + + + +%1$d more track + +%1$d more tracks + + Collaborative + Private + Unknown track + Loading track… + Loading location No Location Permissions diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt new file mode 100644 index 0000000000..5b2dbe4c18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/MusicPlaylistEvent.kt @@ -0,0 +1,116 @@ +/* + * 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.quartz.experimental.music.playlist + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PrivateTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PublicTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicPlaylistEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun isPublic() = tags.firstNotNullOfOrNull(PublicTag::parse) ?: true + + fun isPrivate() = tags.firstNotNullOfOrNull(PrivateTag::parse) ?: false + + fun isCollaborative() = tags.firstNotNullOfOrNull(CollaborativeTag::parse) ?: false + + /** + * Track references as `a` tags pointing to MusicTrackEvent (kind 36787) addressable events. + * Order is preserved as it appears in the tag array. + */ + fun trackAddresses(): List
= + tags.mapNotNull { tag -> + val address = ATag.parseAddress(tag) ?: return@mapNotNull null + if (address.kind == MusicTrackEvent.KIND) address else null + } + + fun trackCount(): Int = trackAddresses().size + + companion object { + const val KIND = 34139 + const val ALT_DESCRIPTION_PREFIX = "Playlist" + const val CATEGORY_TAG = "playlist" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + description: String = "", + image: String? = null, + shortDescription: String? = null, + tracks: List
= emptyList(), + isPrivate: Boolean = false, + isCollaborative: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title") + + title(title) + hashtag(CATEGORY_TAG) + + image?.let { image(it) } + shortDescription?.let { description(it) } + + tracks.forEach { trackAddress(it) } + + if (isPrivate) { + private(true) + } else { + public(true) + } + if (isCollaborative) collaborative(true) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..409c6fce6e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.music.playlist + +import com.vitorpamplona.quartz.experimental.music.playlist.tags.CollaborativeTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.DescriptionTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PrivateTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.PublicTag +import com.vitorpamplona.quartz.experimental.music.playlist.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.public(public: Boolean = true) = addUnique(PublicTag.assemble(public)) + +fun TagArrayBuilder.private(private: Boolean = true) = addUnique(PrivateTag.assemble(private)) + +fun TagArrayBuilder.collaborative(collaborative: Boolean = true) = addUnique(CollaborativeTag.assemble(collaborative)) + +fun TagArrayBuilder.trackAddress(address: Address) = add(ATag.assemble(address, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt new file mode 100644 index 0000000000..3388990ee0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt new file mode 100644 index 0000000000..7076285410 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt new file mode 100644 index 0000000000..8012720620 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt new file mode 100644 index 0000000000..3c7cf71b77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/playlist/tags/VisibilityTag.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.experimental.music.playlist.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PublicTag { + companion object { + const val TAG_NAME = "public" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(public: Boolean = true) = arrayOf(TAG_NAME, if (public) TRUE_VALUE else "false") + } +} + +class PrivateTag { + companion object { + const val TAG_NAME = "private" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(private: Boolean = true) = arrayOf(TAG_NAME, if (private) TRUE_VALUE else "false") + } +} + +class CollaborativeTag { + companion object { + const val TAG_NAME = "collaborative" + const val TRUE_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(TRUE_VALUE, ignoreCase = true) + } + + fun assemble(collaborative: Boolean = true) = arrayOf(TAG_NAME, if (collaborative) TRUE_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt new file mode 100644 index 0000000000..e2a6d9d258 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/MusicTrackEvent.kt @@ -0,0 +1,135 @@ +/* + * 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.quartz.experimental.music.track + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.music.track.tags.AlbumTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ArtistTag +import com.vitorpamplona.quartz.experimental.music.track.tags.BitrateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.DurationTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ExplicitTag +import com.vitorpamplona.quartz.experimental.music.track.tags.FormatTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.LanguageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ReleasedTag +import com.vitorpamplona.quartz.experimental.music.track.tags.SampleRateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TrackNumberTag +import com.vitorpamplona.quartz.experimental.music.track.tags.UrlTag +import com.vitorpamplona.quartz.experimental.music.track.tags.VideoUrlTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Immutable +class MusicTrackEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun artist() = tags.firstNotNullOfOrNull(ArtistTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + fun videoUrl() = tags.firstNotNullOfOrNull(VideoUrlTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun album() = tags.firstNotNullOfOrNull(AlbumTag::parse) + + fun trackNumber() = tags.firstNotNullOfOrNull(TrackNumberTag::parse) + + fun released() = tags.firstNotNullOfOrNull(ReleasedTag::parse) + + fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) + + fun format() = tags.firstNotNullOfOrNull(FormatTag::parse) + + fun bitrate() = tags.firstNotNullOfOrNull(BitrateTag::parse) + + fun sampleRate() = tags.firstNotNullOfOrNull(SampleRateTag::parse) + + fun language() = tags.firstNotNullOfOrNull(LanguageTag::parse) + + fun isExplicit() = tags.firstNotNullOfOrNull(ExplicitTag::parse) ?: false + + companion object { + const val KIND = 36787 + const val ALT_DESCRIPTION_PREFIX = "Music track" + const val GENRE_TAG = "music" + + @OptIn(ExperimentalUuidApi::class) + fun build( + title: String, + artist: String, + url: String, + description: String = "", + image: String? = null, + videoUrl: String? = null, + album: String? = null, + trackNumber: Int? = null, + released: String? = null, + duration: Int? = null, + format: String? = null, + bitrate: String? = null, + sampleRate: Int? = null, + language: String? = null, + explicit: Boolean = false, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + dTag(dTag) + alt("$ALT_DESCRIPTION_PREFIX: $title by $artist") + + title(title) + artist(artist) + url(url) + hashtag(GENRE_TAG) + + image?.let { image(it) } + videoUrl?.let { videoUrl(it) } + album?.let { album(it) } + trackNumber?.let { trackNumber(it) } + released?.let { released(it) } + duration?.let { duration(it) } + format?.let { format(it) } + bitrate?.let { bitrate(it) } + sampleRate?.let { sampleRate(it) } + language?.let { language(it) } + if (explicit) explicit(true) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..edcb643030 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/TagArrayBuilderExt.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.experimental.music.track + +import com.vitorpamplona.quartz.experimental.music.track.tags.AlbumTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ArtistTag +import com.vitorpamplona.quartz.experimental.music.track.tags.BitrateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.DurationTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ExplicitTag +import com.vitorpamplona.quartz.experimental.music.track.tags.FormatTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ImageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.LanguageTag +import com.vitorpamplona.quartz.experimental.music.track.tags.ReleasedTag +import com.vitorpamplona.quartz.experimental.music.track.tags.SampleRateTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TitleTag +import com.vitorpamplona.quartz.experimental.music.track.tags.TrackNumberTag +import com.vitorpamplona.quartz.experimental.music.track.tags.UrlTag +import com.vitorpamplona.quartz.experimental.music.track.tags.VideoUrlTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.artist(artist: String) = addUnique(ArtistTag.assemble(artist)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.videoUrl(url: String) = addUnique(VideoUrlTag.assemble(url)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.album(album: String) = addUnique(AlbumTag.assemble(album)) + +fun TagArrayBuilder.trackNumber(number: Int) = addUnique(TrackNumberTag.assemble(number)) + +fun TagArrayBuilder.released(isoDate: String) = addUnique(ReleasedTag.assemble(isoDate)) + +fun TagArrayBuilder.duration(seconds: Int) = addUnique(DurationTag.assemble(seconds)) + +fun TagArrayBuilder.format(format: String) = addUnique(FormatTag.assemble(format)) + +fun TagArrayBuilder.bitrate(bitrate: String) = addUnique(BitrateTag.assemble(bitrate)) + +fun TagArrayBuilder.sampleRate(hz: Int) = addUnique(SampleRateTag.assemble(hz)) + +fun TagArrayBuilder.language(iso639: String) = addUnique(LanguageTag.assemble(iso639)) + +fun TagArrayBuilder.explicit(explicit: Boolean = true) = addUnique(ExplicitTag.assemble(explicit)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt new file mode 100644 index 0000000000..7a4e925dcf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/AlbumTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class AlbumTag { + companion object { + const val TAG_NAME = "album" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(album: String) = arrayOf(TAG_NAME, album) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt new file mode 100644 index 0000000000..5b576071b7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ArtistTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ArtistTag { + companion object { + const val TAG_NAME = "artist" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(artist: String) = arrayOf(TAG_NAME, artist) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt new file mode 100644 index 0000000000..6dbc83e46f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/BitrateTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class BitrateTag { + companion object { + const val TAG_NAME = "bitrate" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(bitrate: String) = arrayOf(TAG_NAME, bitrate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt new file mode 100644 index 0000000000..4f0dd13aab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/DurationTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(seconds: Int) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt new file mode 100644 index 0000000000..b1332a77d0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ExplicitTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ExplicitTag { + companion object { + const val TAG_NAME = "explicit" + const val EXPLICIT_VALUE = "true" + + fun parse(tag: Array): Boolean? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].equals(EXPLICIT_VALUE, ignoreCase = true) + } + + fun assemble(explicit: Boolean = true) = arrayOf(TAG_NAME, if (explicit) EXPLICIT_VALUE else "false") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt new file mode 100644 index 0000000000..46f0c76776 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/FormatTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FormatTag { + companion object { + const val TAG_NAME = "format" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(format: String) = arrayOf(TAG_NAME, format) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt new file mode 100644 index 0000000000..6f38f93755 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt new file mode 100644 index 0000000000..37ee0c2542 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/LanguageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class LanguageTag { + companion object { + const val TAG_NAME = "language" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(iso639: String) = arrayOf(TAG_NAME, iso639) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt new file mode 100644 index 0000000000..4c330688ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/ReleasedTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ReleasedTag { + companion object { + const val TAG_NAME = "released" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(isoDate: String) = arrayOf(TAG_NAME, isoDate) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt new file mode 100644 index 0000000000..59cbcc4597 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/SampleRateTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SampleRateTag { + companion object { + const val TAG_NAME = "sample_rate" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(hz: Int) = arrayOf(TAG_NAME, hz.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt new file mode 100644 index 0000000000..03b77db812 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt new file mode 100644 index 0000000000..f82957b223 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/TrackNumberTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TrackNumberTag { + companion object { + const val TAG_NAME = "track_number" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(trackNumber: Int) = arrayOf(TAG_NAME, trackNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt new file mode 100644 index 0000000000..b3a9b68195 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/UrlTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt new file mode 100644 index 0000000000..fdc205a38b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/music/track/tags/VideoUrlTag.kt @@ -0,0 +1,39 @@ +/* + * 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.quartz.experimental.music.track.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class VideoUrlTag { + companion object { + const val TAG_NAME = "video" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 993c414635..08ffa2caef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent +import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.SoftwareApplicationEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent @@ -463,6 +465,8 @@ class EventFactory { MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + MusicPlaylistEvent.KIND -> MusicPlaylistEvent(id, pubKey, createdAt, tags, content, sig) + MusicTrackEvent.KIND -> MusicTrackEvent(id, pubKey, createdAt, tags, content, sig) MuteListEvent.KIND -> MuteListEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageEvent.KIND -> KeyPackageEvent(id, pubKey, createdAt, tags, content, sig) KeyPackageRelayListEvent.KIND -> KeyPackageRelayListEvent(id, pubKey, createdAt, tags, content, sig)