diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index ec55ffdae..1265454ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -28,10 +28,15 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav") +val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet() + object NostrVideoDataSource : NostrDataSource("VideoFeed") { lateinit var account: Account @@ -40,8 +45,6 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { var job: Job? = null - val SUPPORTED_VIDEO_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav") - override fun start() { job?.cancel() job = @@ -68,9 +71,9 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { filter = JsonFilter( authors = follows, - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), limit = 200, - tags = mapOf("m" to SUPPORTED_VIDEO_MIME_TYPES), + tags = mapOf("m" to SUPPORTED_VIDEO_FEED_MIME_TYPES), since = latestEOSEs.users[account.userProfile()] ?.followList @@ -89,14 +92,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf( "t" to hashToLoad .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } .flatten(), - "m" to SUPPORTED_VIDEO_MIME_TYPES, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, ), limit = 100, since = @@ -117,14 +120,14 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND), + kinds = listOf(FileHeaderEvent.KIND, FileStorageHeaderEvent.KIND, VideoHorizontalEvent.KIND, VideoVerticalEvent.KIND), tags = mapOf( "g" to hashToLoad .map { listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) } .flatten(), - "m" to SUPPORTED_VIDEO_MIME_TYPES, + "m" to SUPPORTED_VIDEO_FEED_MIME_TYPES, ), limit = 100, since = diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt index adfd3dd74..664fbbb3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt @@ -23,10 +23,13 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.events.VideoHorizontalEvent +import com.vitorpamplona.quartz.events.VideoVerticalEvent class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { override fun feedKey(): String { @@ -65,7 +68,12 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter() { ): Boolean { val noteEvent = it.event - return ((noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isImageOrVideo()) || (noteEvent is FileStorageHeaderEvent && noteEvent.isImageOrVideo())) && + return ( + (noteEvent is FileHeaderEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + (noteEvent is VideoVerticalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + (noteEvent is VideoHorizontalEvent && noteEvent.hasUrl() && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) || + (noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) + ) && params.match(noteEvent) && account.isAcceptable(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5bcc4f7d9..50a53279f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack @@ -174,6 +175,7 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent @@ -305,6 +307,7 @@ fun AcceptableNote( is BadgeDefinitionEvent -> BadgeDisplay(baseNote = baseNote) is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, false, accountViewModel) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, false, accountViewModel) + is VideoEvent -> JustVideoDisplay(baseNote, false, false, accountViewModel) else -> LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt new file mode 100644 index 000000000..1d3fec9b5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -0,0 +1,88 @@ +/** + * Copyright (c) 2024 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.VideoEvent + +@Composable +fun JustVideoDisplay( + note: Note, + roundedCorner: Boolean, + isFiniteHeight: Boolean, + accountViewModel: AccountViewModel, +) { + val event = (note.event as? VideoEvent) ?: return + val fullUrl = event.url() ?: return + + val content by + remember(note) { + val blurHash = event.blurhash() + val hash = event.hash() + val dimensions = event.dimensions() + val description = event.content.ifEmpty { null } ?: event.alt() + val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) + val uri = note.toNostrUri() + + mutableStateOf( + if (isImage) { + MediaUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + ) + } else { + MediaUrlVideo( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName(), + ) + }, + ) + } + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ZoomableContentView( + content = content, + roundedCorner = roundedCorner, + isFiniteHeight = isFiniteHeight, + accountViewModel = accountViewModel, + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 6c084f725..447b9e482 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedState @@ -104,6 +105,7 @@ import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import com.vitorpamplona.quartz.events.VideoEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -331,6 +333,8 @@ private fun RenderVideoOrPictureNote( FileHeaderDisplay(note, false, true, accountViewModel) } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(note, false, true, accountViewModel) + } else if (noteEvent is VideoEvent) { + JustVideoDisplay(note, false, true, accountViewModel) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt index ffd343a1a..2dd61b26b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileHeaderEvent.kt @@ -58,11 +58,7 @@ class FileHeaderEvent( fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } - fun isImageOrVideo(): Boolean { - val mimeType = mimeType() ?: return false - - return mimeType.startsWith("image/") || mimeType.startsWith("video/") - } + fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == MIME_TYPE && mimeTypes.contains(it[1]) } companion object { const val KIND = 1063 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt index 2cc3cb006..a3e85cdfa 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/FileStorageHeaderEvent.kt @@ -54,11 +54,7 @@ class FileStorageHeaderEvent( fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1) - fun isImageOrVideo(): Boolean { - val mimeType = mimeType() ?: return false - - return mimeType.startsWith("image/") || mimeType.startsWith("video/") - } + fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) } companion object { const val KIND = 1065 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt index 54a438953..4f037e790 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoEvent.kt @@ -65,6 +65,8 @@ abstract class VideoEvent( fun hasUrl() = tags.any { it.size > 1 && it[0] == URL } + fun isOneOf(mimeTypes: Set) = tags.any { it.size > 1 && it[0] == FileHeaderEvent.MIME_TYPE && mimeTypes.contains(it[1]) } + companion object { private const val URL = "url" private const val ENCRYPTION_KEY = "aes-256-gcm" @@ -84,7 +86,7 @@ abstract class VideoEvent( private const val IMAGE = "image" private const val THUMB = "thumb" - fun create( + fun create( kind: Int, url: String, magnetUri: String? = null, @@ -102,7 +104,7 @@ abstract class VideoEvent( altDescription: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, + onReady: (T) -> Unit, ) { val tags = listOfNotNull( @@ -128,7 +130,7 @@ abstract class VideoEvent( ) val content = alt ?: "" - signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) + signer.sign(createdAt, kind, tags.toTypedArray(), content, onReady) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt index b9acc1cd7..7f8389f47 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoHorizontalEvent.kt @@ -54,7 +54,7 @@ class VideoHorizontalEvent( sensitiveContent: Boolean? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, + onReady: (VideoHorizontalEvent) -> Unit, ) { create( KIND, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt index 6787627f8..66e9cf2b5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/VideoVerticalEvent.kt @@ -54,7 +54,7 @@ class VideoVerticalEvent( sensitiveContent: Boolean? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (FileHeaderEvent) -> Unit, + onReady: (VideoVerticalEvent) -> Unit, ) { create( KIND,