diff --git a/app/build.gradle b/app/build.gradle index 0ea57e78d..670dd4396 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,14 +13,14 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 256 - versionName "0.69.3" + versionCode 258 + versionName "0.70.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary true } - resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja") + resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja") } buildTypes { @@ -177,6 +177,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.media3:media3-exoplayer-hls:1.1.0' implementation 'androidx.media3:media3-ui:1.1.0' + implementation 'androidx.media3:media3-session:1.1.0' // Local model for language identification playImplementation 'com.google.mlkit:language-id:17.0.4' diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt index 4ce6cf759..25f0fe21a 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/RichTextParserTest.kt @@ -4007,7 +4007,7 @@ class RichTextParserTest { "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)" ) - state.paragraphs.flatten().forEachIndexed { index, seg -> + state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" @@ -4027,7 +4027,7 @@ class RichTextParserTest { Assert.assertTrue(state.imagesForPager.isEmpty()) Assert.assertTrue(state.imageList.isEmpty()) Assert.assertTrue(state.customEmoji.isEmpty()) - Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.firstOrNull()?.segmentText) + Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText) } @Test @@ -4039,7 +4039,7 @@ class RichTextParserTest { Assert.assertTrue(state.customEmoji.isEmpty()) Assert.assertEquals( "\nHi, \nhow\n\n\n are you doing? \n", - state.paragraphs.joinToString("\n") { it.joinToString(" ") { it.segmentText } } + state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } } ) } @@ -4102,7 +4102,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)" ) - state.paragraphs.flatten().forEachIndexed { index, seg -> + state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg -> Assert.assertEquals( expectedResult[index], "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" @@ -4112,7 +4112,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db private fun printStateForDebug(state: RichTextViewerState) { state.paragraphs.forEachIndexed { index, paragraph -> - paragraph.forEach { seg -> + paragraph.words.forEach { seg -> println( "\"${ seg.javaClass.simpleName.replace( diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1accea6b1..795994bc8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,13 @@ + + + + + + + @@ -91,6 +99,17 @@ android:screenOrientation="fullSensor" tools:replace="screenOrientation" /> + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/MultiPlayerPlaybackManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/MultiPlayerPlaybackManager.kt new file mode 100644 index 000000000..022fbbf96 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/MultiPlayerPlaybackManager.kt @@ -0,0 +1,121 @@ +package com.vitorpamplona.amethyst + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.LruCache +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Player.STATE_READY +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import com.vitorpamplona.amethyst.ui.MainActivity +import kotlin.math.abs + +class MultiPlayerPlaybackManager(private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null) { + private val cachedPositions = LruCache(100) + + // protects from LruCache killing playing sessions + private val playingMap = mutableMapOf() + + private val cache = + object : LruCache(10) { // up to 10 videos in the screen at the same time + override fun entryRemoved( + evicted: Boolean, + key: String?, + oldValue: MediaSession?, + newValue: MediaSession? + ) { + super.entryRemoved(evicted, key, oldValue, newValue) + + if (!playingMap.contains(key)) { + oldValue?.let { + it.player.release() + it.release() + } + } + } + } + + private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent { + return PendingIntent.getActivity( + applicationContext, + 0, + Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession { + val existingSession = playingMap.get(id) ?: cache.get(id) + if (existingSession != null) return existingSession + + val player = ExoPlayer.Builder(context).run { + dataSourceFactory?.let { setMediaSourceFactory(it) } + build() + } + + player.apply { + repeatMode = Player.REPEAT_MODE_ALL + videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT + volume = 0f + setWakeMode(C.WAKE_MODE_NETWORK) + } + + val mediaSession = MediaSession.Builder(context, player).run { + callbackUri?.let { + setSessionActivity(getCallbackIntent(it, applicationContext)) + } + setId(id) + build() + } + + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + playingMap.put(id, mediaSession) + } else { + cache.put(id, mediaSession) + playingMap.remove(id, mediaSession) + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + STATE_IDLE -> { + if (player.currentPosition > 5 * 60) { // 5 seconds + cachedPositions.put(uri, player.currentPosition) + } + } + STATE_READY -> { + cachedPositions.get(uri)?.let { lastPosition -> + if (abs(player.currentPosition - lastPosition) > 5 * 60) { + player.seekTo(lastPosition) + } + } + } + else -> { + if (player.currentPosition > 5 * 60) { // 5 seconds + cachedPositions.put(uri, player.currentPosition) + } + } + } + } + }) + + cache.put(id, mediaSession) + + return mediaSession + } + + fun releaseAppPlayers() { + cache.evictAll() + } + + fun playingContent(): Collection { + return playingMap.values + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/PlaybackClientController.kt b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackClientController.kt new file mode 100644 index 000000000..d680fe815 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackClientController.kt @@ -0,0 +1,40 @@ +package com.vitorpamplona.amethyst + +import android.content.ComponentName +import android.content.Context +import android.os.Bundle +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors + +object PlaybackClientController { + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun prepareController( + controllerID: String, + videoUri: String, + callbackUri: String?, + context: Context, + onReady: (MediaController) -> Unit + ) { + // creating a bundle object + // creating a bundle object + val bundle = Bundle() + bundle.putString("id", controllerID) + bundle.putString("uri", videoUri) + bundle.putString("callbackUri", callbackUri) + + val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + val controllerFuture = MediaController + .Builder(context, sessionTokenLocal) + .setConnectionHints(bundle) + .buildAsync() + + controllerFuture.addListener( + { + val controller = controllerFuture.get() + onReady(controller) + }, + MoreExecutors.directExecutor() + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt new file mode 100644 index 000000000..33ae769c8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/PlaybackService.kt @@ -0,0 +1,99 @@ +package com.vitorpamplona.amethyst + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import com.vitorpamplona.amethyst.service.HttpClient + +@UnstableApi // Extend MediaSessionService +class PlaybackService : MediaSessionService() { + private val managerHls = MultiPlayerPlaybackManager(HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))) + private val managerProgressive = MultiPlayerPlaybackManager(ProgressiveMediaSource.Factory(VideoCache.get())) + private val managerLocal = MultiPlayerPlaybackManager() + + // Create your Player and MediaSession in the onCreate lifecycle event + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + + setMediaNotificationProvider( + DefaultMediaNotificationProvider.Builder(applicationContext) + // .setNotificationIdProvider { session -> session.id.hashCode() } + .build() + ) + } + + override fun onDestroy() { + managerHls.releaseAppPlayers() + managerLocal.releaseAppPlayers() + managerProgressive.releaseAppPlayers() + + super.onDestroy() + } + + fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager { + return if (fileName.startsWith("file")) { + managerLocal + } else if (fileName.endsWith("m3u8")) { + managerHls + } else { + managerProgressive + } + } + + override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) { + // Updates any new player ready + super.onUpdateNotification(session, startInForegroundRequired) + + // Overrides the notification with any player actually playing + managerHls.playingContent().forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(it, startInForegroundRequired) + } + } + managerLocal.playingContent().forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + managerProgressive.playingContent().forEach { + if (it.player.isPlaying) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + + // Overrides again with playing with audio + managerHls.playingContent().forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(it, startInForegroundRequired) + } + } + managerLocal.playingContent().forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + managerProgressive.playingContent().forEach { + if (it.player.isPlaying && it.player.volume > 0) { + super.onUpdateNotification(session, startInForegroundRequired) + } + } + } + + // Return a MediaSession to link with the MediaController that is making + // this request. + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + val id = controllerInfo.connectionHints.getString("id") ?: return null + val uri = controllerInfo.connectionHints.getString("uri") ?: return null + val callbackUri = controllerInfo.connectionHints.getString("callbackUri") + + val manager = getAppropriateMediaSessionManager(uri) + + return manager.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt index 5186d43c0..54cc339be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt @@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.HttpClient lateinit var cacheDataSourceFactory: CacheDataSource.Factory @Synchronized + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) fun init(context: Context) { if (!this::simpleCache.isInitialized) { exoDatabaseProvider = StandaloneDatabaseProvider(context) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoViewedPositionCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoViewedPositionCache.kt new file mode 100644 index 000000000..7d3bee4ac --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoViewedPositionCache.kt @@ -0,0 +1,15 @@ +package com.vitorpamplona.amethyst + +import android.util.LruCache + +object VideoViewedPositionCache { + val cachedPosition = LruCache(10) + + fun add(uri: String, position: Long) { + cachedPosition.put(uri, position) + } + + fun get(uri: String): Long? { + return cachedPosition.get(uri) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 47559be95..7cb41000e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -95,6 +95,10 @@ abstract class Channel(val idHex: String) { return null } + open fun creatorName(): String? { + return creator?.toBestDisplayName() + } + open fun profilePicture(): String? { return creator?.profilePicture() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index f060b8152..d1faff809 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -75,6 +75,10 @@ open class Note(val idHex: String) { return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()) } + fun toNostrUri(): String { + return "nostr:${toNEvent()}" + } + open fun idDisplayNote() = idNote().toShortenHex() fun channelHex(): HexKey? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 2f083c7af..2e48e3355 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -51,6 +51,8 @@ class User(val pubkeyHex: String) { fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() + fun toNostrUri() = "nostr:${pubkeyNpub()}" + override fun toString(): String = pubkeyHex fun toBestDisplayName(): String { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index ed3554f34..abe1249be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.TimeUtils import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.nip19.Nip19 import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Secp256k1 import nostr.postr.Utils @@ -132,6 +133,18 @@ open class Event( } } + open fun toNIP19(): String { + return if (this is AddressableEvent) { + ATag(kind, pubKey, dTag(), null).toNAddr() + } else { + Nip19.createNEvent(id, pubKey, kind, null) + } + } + + fun toNostrUri(): String { + return "nostr:${toNIP19()}" + } + /** * Checks if the ID is correct and then if the pubKey's secret key signed the event. */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 341ff157f..37ebf891f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -6,18 +6,28 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.os.LocaleListCompat import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.ServiceManager @@ -25,13 +35,17 @@ import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent +import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting +import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.debugState +import com.vitorpamplona.amethyst.ui.navigation.getRouteWithArguments import com.vitorpamplona.amethyst.ui.note.Nip47 import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel @@ -45,11 +59,13 @@ import java.net.URLEncoder import java.nio.charset.StandardCharsets class MainActivity : AppCompatActivity() { + lateinit var navController: NavHostController + + @RequiresApi(Build.VERSION_CODES.R) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val uri = intent?.data?.toString() - val startingPage = uriToRoute(uri) LocalPreferences.migrateSingleUserPrefs() @@ -58,8 +74,11 @@ class MainActivity : AppCompatActivity() { val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language) AppCompatDelegate.setApplicationLocales(appLocale) } + setContent { + navController = rememberNavController() val themeViewModel: ThemeViewModel = viewModel() + themeViewModel.onChange(LocalPreferences.getTheme()) AmethystTheme(themeViewModel) { // A surface container using the 'background' color from the theme @@ -68,9 +87,17 @@ class MainActivity : AppCompatActivity() { AccountStateViewModel(this@MainActivity) } - AccountScreen(accountStateViewModel, themeViewModel, startingPage) + AccountScreen(accountStateViewModel, themeViewModel, navController) } } + + var actionableNextPage by remember { mutableStateOf(startingPage) } + actionableNextPage?.let { + LaunchedEffect(it) { + navController.navigate(it) + } + actionableNextPage = null + } } val networkRequest = NetworkRequest.Builder() @@ -88,6 +115,7 @@ class MainActivity : AppCompatActivity() { @OptIn(DelicateCoroutinesApi::class) override fun onResume() { super.onResume() + // starts muted every time DefaultMutedSetting.value = true @@ -109,6 +137,11 @@ class MainActivity : AppCompatActivity() { super.onPause() } + override fun onDestroy() { + super.onDestroy() + keepPlayingMutex?.stop() + } + /** * Release memory when the UI becomes hidden or when system resources become low. * @param level the memory-related event that was raised. @@ -121,6 +154,39 @@ class MainActivity : AppCompatActivity() { } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + val uri = intent?.data?.toString() + val startingPage = uriToRoute(uri) + + startingPage?.let { route -> + val currentRoute = getRouteWithArguments(navController) + if (!isSameRoute(currentRoute, route)) { + navController.navigate(route) { + popUpTo(Route.Home.route) + launchSingleTop = true + } + } + } + } + + private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean { + if (currentRoute == null) return false + + if (currentRoute == newRoute) { + return true + } + + if (newRoute.startsWith("Event/") && currentRoute.contains("/")) { + if (newRoute.split("/")[1] == currentRoute.split("/")[1]) { + return true + } + } + + return false + } + private val networkCallback = object : ConnectivityManager.NetworkCallback() { // network is available for use override fun onAvailable(network: Network) { @@ -196,7 +262,14 @@ fun uriToRoute(uri: String?): String? { } } - Nip19.Type.ADDRESS -> "Note/${nip19.hex}" + Nip19.Type.ADDRESS -> + if (nip19.kind == CommunityDefinitionEvent.kind) { + "Community/${nip19.hex}" + } else if (nip19.kind == LiveActivitiesEvent.kind) { + "Channel/${nip19.hex}" + } else { + "Event/${nip19.hex}" + } else -> null } } ?: try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index f772dedc0..b99becd4e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -238,7 +238,10 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo } } else { postViewModel.galleryUri?.let { - VideoView(it.toString(), accountViewModel = accountViewModel) + VideoView( + videoUri = it.toString(), + accountViewModel = accountViewModel + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 4771a1547..a6ae9dbc4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -1,10 +1,16 @@ package com.vitorpamplona.amethyst.ui.components +import android.graphics.Rect import android.graphics.drawable.Drawable +import android.net.Uri import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -22,12 +28,13 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -39,38 +46,41 @@ import androidx.compose.ui.unit.isFinite import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata import androidx.media3.common.Player -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.okhttp.OkHttpDataSource -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.hls.HlsMediaSource -import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import coil.imageLoader import coil.request.ImageRequest -import com.vitorpamplona.amethyst.VideoCache -import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.PlaybackClientController import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus +import com.vitorpamplona.amethyst.ui.note.LyricsIcon +import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon import com.vitorpamplona.amethyst.ui.note.MuteIcon import com.vitorpamplona.amethyst.ui.note.MutedIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue +import java.util.UUID +import kotlin.math.abs public var DefaultMutedSetting = mutableStateOf(true) @Composable fun LoadThumbAndThenVideoView( videoUri: String, - description: String? = null, + title: String? = null, thumbUri: String, + authorName: String? = null, + nostrUriCallback: String? = null, accountViewModel: AccountViewModel, onDialog: ((Boolean) -> Unit)? = null ) { @@ -97,95 +107,73 @@ fun LoadThumbAndThenVideoView( if (loadingFinished.first) { if (loadingFinished.second != null) { - VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog) + VideoView( + videoUri = videoUri, + title = title, + thumb = VideoThumb(loadingFinished.second), + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog + ) } else { - VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog) + VideoView( + videoUri = videoUri, + title = title, + thumb = null, + artworkUri = thumbUri, + authorName = authorName, + nostrUriCallback = nostrUriCallback, + accountViewModel = accountViewModel, + onDialog = onDialog + ) } } } -@OptIn(ExperimentalTime::class) @Composable fun VideoView( videoUri: String, - description: String? = null, - thumb: VideoThumb? = null, - accountViewModel: AccountViewModel, - alwaysShowVideo: Boolean = false, - onDialog: ((Boolean) -> Unit)? = null -) { - val (value, elapsed) = measureTimedValue { - VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo) - } - Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") -} - -@Composable -fun VideoView1( - videoUri: String, - description: String? = null, + title: String? = null, thumb: VideoThumb? = null, + artworkUri: String? = null, + authorName: String? = null, + nostrUriCallback: String? = null, onDialog: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false ) { - var exoPlayerData by remember { mutableStateOf(null) } - val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) } - val context = LocalContext.current + val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) } - LaunchedEffect(key1 = videoUri) { - if (exoPlayerData == null) { - launch(Dispatchers.Default) { - exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build()) - } - } - } - - exoPlayerData?.let { - VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo) - } - - DisposableEffect(Unit) { - onDispose { - exoPlayerData?.exoPlayer?.release() - } - } -} - -@OptIn(ExperimentalTime::class) -@Composable -fun VideoView( - videoUri: String, - description: String? = null, - exoPlayerData: VideoPlayer, - defaultToStart: Boolean = false, - thumb: VideoThumb? = null, - onDialog: ((Boolean) -> Unit)? = null, - accountViewModel: AccountViewModel, - alwaysShowVideo: Boolean = false -) { - val (_, elapsed) = measureTimedValue { - VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo) - } - Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") + VideoViewInner( + videoUri, + defaultToStart, + title, + thumb, + artworkUri, + authorName, + nostrUriCallback, + alwaysShowVideo, + accountViewModel, + onDialog + ) } @Composable @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) -fun VideoView1( +fun VideoViewInner( videoUri: String, - description: String? = null, - exoPlayerData: VideoPlayer, defaultToStart: Boolean = false, + title: String? = null, thumb: VideoThumb? = null, - onDialog: ((Boolean) -> Unit)? = null, + artworkUri: String? = null, + authorName: String? = null, + nostrUriCallback: String? = null, + alwaysShowVideo: Boolean = false, accountViewModel: AccountViewModel, - alwaysShowVideo: Boolean = false + onDialog: ((Boolean) -> Unit)? = null ) { - val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) - - val media = remember { MediaItem.Builder().setUri(videoUri).build() } - val settings = accountViewModel.account.settings val isMobile = ConnectivityStatus.isOnMobileData.value @@ -201,58 +189,281 @@ fun VideoView1( ) } - exoPlayerData.exoPlayer.apply { - repeatMode = Player.REPEAT_MODE_ALL - videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT - volume = if (defaultToStart) 0f else 1f - if (videoUri.startsWith("file")) { - setMediaItem(media) - } else if (videoUri.endsWith("m3u8")) { - // Should not use cache. - val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient()) - setMediaSource( - HlsMediaSource.Factory(dataSourceFactory).createMediaSource( - media - ) - ) - } else { - setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( - media - ) - ) - } - prepare() - } - if (!automaticallyStartPlayback.value) { ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) } else { - RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog) - } - - DisposableEffect(Unit) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_PAUSE -> { - exoPlayerData.exoPlayer.pause() - } - else -> {} + VideoPlayerMutex(videoUri) { activeOnScreen -> + val mediaItem = remember(videoUri) { + MediaItem.Builder() + .setMediaId(videoUri) + .setUri(videoUri) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(authorName?.ifBlank { null }) + .setTitle(title?.ifBlank { null } ?: videoUri) + .setArtworkUri( + try { + if (artworkUri != null) { + Uri.parse(artworkUri) + } else { + null + } + } catch (e: Exception) { + null + } + ) + .build() + ) + .build() } - } - val lifecycle = lifecycleOwner.value.lifecycle - lifecycle.addObserver(observer) - onDispose { - lifecycle.removeObserver(observer) + GetVideoController( + mediaItem = mediaItem, + videoUri = videoUri, + defaultToStart = defaultToStart, + nostrUriCallback = nostrUriCallback + ) { controller, keepPlaying -> + RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog) + } } } } +@Composable +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +fun GetVideoController( + mediaItem: MediaItem, + videoUri: String, + defaultToStart: Boolean = false, + nostrUriCallback: String? = null, + inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit +) { + val context = LocalContext.current + + val controller = remember(videoUri) { + mutableStateOf( + if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) keepPlayingMutex else null + ) + } + + val keepPlaying = remember(videoUri) { + mutableStateOf( + keepPlayingMutex != null && controller.value == keepPlayingMutex + ) + } + + val uid = remember(videoUri) { + UUID.randomUUID().toString() + } + + // Prepares a VideoPlayer from the foreground service. + LaunchedEffect(key1 = videoUri) { + // If it is not null, the user might have come back from a playing video, like clicking on + // the notification of the video player. + if (controller.value == null) { + launch(Dispatchers.IO) { + PlaybackClientController.prepareController( + uid, + videoUri, + nostrUriCallback, + context + ) { + // checks again because of race conditions. + if (controller.value == null) { // still prone to race conditions. + controller.value = it + + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + controller.value?.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + controller.value?.volume = if (defaultToStart) 0f else 1f + } + } + + controller.value?.setMediaItem(mediaItem) + controller.value?.prepare() + } else if (controller.value != it) { + // discards the new controller because there is an existing one + it.stop() + it.release() + + controller.value?.let { + if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { + if (it.isPlaying) { + // There is a video playing, start this one on mute. + it.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + it.volume = if (defaultToStart) 0f else 1f + } + + it.setMediaItem(mediaItem) + it.prepare() + } + } + } + } + } + } else { + controller.value?.let { + if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { + if (it.isPlaying) { + // There is a video playing, start this one on mute. + it.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + it.volume = if (defaultToStart) 0f else 1f + } + + it.setMediaItem(mediaItem) + it.prepare() + } + } + } + } + + controller.value?.let { + inner(it, keepPlaying) + } + + // User pauses and resumes the app. What to do with videos? + val scope = rememberCoroutineScope() + val lifeCycleOwner = LocalLifecycleOwner.current + DisposableEffect(key1 = videoUri) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + // if the controller is null, restarts the controller with a new one + // if the controller is not null, just continue playing what the controller was playing + if (controller.value == null) { + scope.launch(Dispatchers.IO) { + PlaybackClientController.prepareController( + UUID.randomUUID().toString(), + videoUri, + nostrUriCallback, + context + ) { + // checks again to make sure no other thread has created a controller. + if (controller.value == null) { + controller.value = it + + if (!it.isPlaying) { + if (keepPlayingMutex?.isPlaying == true) { + // There is a video playing, start this one on mute. + controller.value?.volume = 0f + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + controller.value?.volume = if (defaultToStart) 0f else 1f + } + } + + controller.value?.setMediaItem(mediaItem) + controller.value?.prepare() + } else if (controller.value != it) { + // discards the new controller because there is an existing one + it.stop() + it.release() + } + } + } + } + } + if (event == Lifecycle.Event.ON_PAUSE) { + if (!keepPlaying.value) { + // Stops and releases the media. + controller.value?.stop() + controller.value?.release() + controller.value = null + } + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + + if (!keepPlaying.value) { + // Stops and releases the media. + controller.value?.stop() + controller.value?.release() + controller.value = null + } + } + } +} + +// background playing mutex. +var keepPlayingMutex: MediaController? = null + +// This keeps the position of all visible videos in the current screen. +val trackingVideos = mutableListOf() + @Stable -data class VideoPlayer( - val exoPlayer: ExoPlayer -) +class VisibilityData() { + var distanceToCenter: Float? = null +} + +/** + * This function selects only one Video to be active. The video that is closest to the center of + * the screen wins the mutex. + */ +@Composable +fun VideoPlayerMutex(videoUri: String, inner: @Composable (MutableState) -> Unit) { + val myCache = remember(videoUri) { + VisibilityData() + } + + // Is the current video the closest to the center? + val active = remember(videoUri) { + mutableStateOf(false) + } + + // Keep track of all available videos. + DisposableEffect(key1 = videoUri) { + trackingVideos.add(myCache) + onDispose { + trackingVideos.remove(myCache) + } + } + + Box( + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 70.dp) + .onVisiblePositionChanges { distanceToCenter -> + myCache.distanceToCenter = distanceToCenter + + if (distanceToCenter != null) { + // finds out of the current video is the closest to the center. + var newActive = true + for (video in trackingVideos) { + val videoPos = video.distanceToCenter + if (videoPos != null && videoPos < distanceToCenter) { + newActive = false + break + } + } + + // marks the current video active + if (active.value != newActive) { + active.value = newActive + } + } else { + // got out of screen, marks video as inactive + if (active.value) { + active.value = false + } + } + } + ) { + inner(active) + } +} @Stable data class VideoThumb( @@ -260,36 +471,32 @@ data class VideoThumb( ) @Composable -@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@OptIn(UnstableApi::class) private fun RenderVideoPlayer( - playerData: VideoPlayer, + controller: MediaController, thumbData: VideoThumb?, + keepPlaying: MutableState, automaticallyStartPlayback: MutableState, + activeOnScreen: MutableState, onDialog: ((Boolean) -> Unit)? ) { val context = LocalContext.current + ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) + + val controllerVisible = remember(controller) { + mutableStateOf(false) + } + BoxWithConstraints() { AndroidView( modifier = Modifier .fillMaxWidth() .defaultMinSize(minHeight = 70.dp) - .align(Alignment.Center) - .onVisibilityChanges { visible -> - if (!automaticallyStartPlayback.value) { - playerData.exoPlayer.stop() - } - if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) { - playerData.exoPlayer.pause() - } else if (visible && !playerData.exoPlayer.isPlaying) { - playerData.exoPlayer.play() - } else if (!visible && playerData.exoPlayer.isPlaying) { - playerData.exoPlayer.pause() - } - }, + .align(Alignment.Center), factory = { PlayerView(context).apply { - player = playerData.exoPlayer + player = controller layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT @@ -301,18 +508,102 @@ private fun RenderVideoPlayer( if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { - playerData.exoPlayer.pause() + controller.pause() innerOnDialog(it) } } + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { + controllerVisible.value = it == View.VISIBLE + } + ) } } ) - MuteButton() { mute: Boolean -> + val startingMuteState = remember(controller) { + controller.volume < 0.001 + } + + MuteButton(controllerVisible, startingMuteState) { mute: Boolean -> + // makes the new setting the default for new creations. DefaultMutedSetting.value = mute - playerData.exoPlayer.volume = if (mute) 0f else 1f + // if the user unmutes a video and it's not the current playing, switches to that one. + if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + keepPlayingMutex = null + } + + controller.volume = if (mute) 0f else 1f + } + + KeepPlayingButton(keepPlaying, controllerVisible, Modifier.align(Alignment.TopEnd)) { newKeepPlaying: Boolean -> + // If something else is playing and the user marks this video to keep playing, stops the other one. + if (newKeepPlaying) { + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + keepPlayingMutex?.stop() + } + keepPlayingMutex = controller + } else { + if (keepPlayingMutex == controller) { + keepPlayingMutex = null + } + } + + keepPlaying.value = newKeepPlaying + } + } +} + +@Composable +fun ControlWhenPlayerIsActive( + controller: Player, + keepPlaying: MutableState, + automaticallyStartPlayback: MutableState, + activeOnScreen: MutableState +) { + // active means being fully visible + if (activeOnScreen.value) { + // should auto start video from settings? + if (!automaticallyStartPlayback.value) { + if (controller.isPlaying) { + // if it is visible, it's playing but it wasn't supposed to start automatically. + controller.pause() + } + } else if (!controller.isPlaying) { + // if it is visible, was supposed to start automatically, but it's not + + // If something else is playing, play on mute. + if (keepPlayingMutex != null && keepPlayingMutex != controller) { + controller.volume = 0f + } + controller.play() + } + } else { + // Pauses the video when it becomes invisible. + // Destroys the video later when it Disposes the element + // meanwhile if the user comes back, the position in the track is saved. + if (!keepPlaying.value) { + controller.pause() + } + } + + val view = LocalView.current + + // Keeps the screen on while playing and viewing videos. + DisposableEffect(key1 = controller) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + view.keepScreenOn = isPlaying + } + } + + controller.addListener(listener) + onDispose { + controller.removeListener(listener) } } } @@ -333,12 +624,15 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean { if (!isAttached) return false // Window relative bounds of our compose root view that are visible on the screen - val globalRootRect = android.graphics.Rect() + val globalRootRect = Rect() if (!view.getGlobalVisibleRect(globalRootRect)) { // we aren't visible at all. return false } val bounds = boundsInWindow() + + if (bounds.isEmpty) return false + // Make sure we are completely in bounds. return bounds.top >= globalRootRect.top && bounds.left >= globalRootRect.left && @@ -346,30 +640,127 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean { bounds.bottom <= globalRootRect.bottom } +fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed { + val view = LocalView.current + + onGloballyPositioned { coordinates -> + onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view)) + } +} + +fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { + if (!isAttached) return null + // Window relative bounds of our compose root view that are visible on the screen + val globalRootRect = Rect() + if (!view.getGlobalVisibleRect(globalRootRect)) { + // we aren't visible at all. + return null + } + + val bounds = boundsInWindow() + + if (bounds.isEmpty) return null + + // Make sure we are completely in bounds. + if (bounds.top >= globalRootRect.top && + bounds.left >= globalRootRect.left && + bounds.right <= globalRootRect.right && + bounds.bottom <= globalRootRect.bottom + ) { + return abs(((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2)) + } + + return null +} + @Composable -private fun MuteButton(toggle: (Boolean) -> Unit) { - Box(modifier = VolumeBottomIconSize) { - Box( - Modifier - .clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colors.background) +private fun MuteButton( + controllerVisible: MutableState, + startingMuteState: Boolean, + toggle: (Boolean) -> Unit +) { + val holdOn = remember { + mutableStateOf( + true ) + } - val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) } + LaunchedEffect(key1 = controllerVisible) { + launch(Dispatchers.Default) { + delay(2000) + holdOn.value = false + } + } - IconButton( - onClick = { - mutedInstance.value = !mutedInstance.value - toggle(mutedInstance.value) - }, - modifier = Size50Modifier - ) { - if (mutedInstance.value) { - MutedIcon() - } else { - MuteIcon() + val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } + + AnimatedVisibility( + visible = holdOn.value || controllerVisible.value, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = VolumeBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + IconButton( + onClick = { + mutedInstance.value = !mutedInstance.value + toggle(mutedInstance.value) + }, + modifier = Size50Modifier + ) { + if (mutedInstance.value) { + MutedIcon() + } else { + MuteIcon() + } + } + } + } +} + +@Composable +private fun KeepPlayingButton( + keepPlayingStart: MutableState, + controllerVisible: MutableState, + alignment: Modifier, + toggle: (Boolean) -> Unit +) { + val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } + + AnimatedVisibility( + visible = controllerVisible.value, + modifier = alignment, + enter = fadeIn(), + exit = fadeOut() + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colors.background) + ) + + IconButton( + onClick = { + keepPlaying.value = !keepPlaying.value + toggle(keepPlaying.value) + }, + modifier = Size50Modifier + ) { + if (keepPlaying.value) { + LyricsIcon(Size22Modifier, Color.White) + } else { + LyricsOffIcon(Size22Modifier, Color.White) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 9a7f02c92..21e7651ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -1,8 +1,11 @@ package com.vitorpamplona.amethyst.ui.components +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.os.Build import android.util.Log +import android.view.Window import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -56,6 +59,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isFinite import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import androidx.core.net.toUri import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage @@ -119,7 +123,9 @@ class ZoomableUrlVideo( description: String? = null, hash: String? = null, dim: String? = null, - uri: String? = null + uri: String? = null, + val artworkUri: String? = null, + val authorName: String? = null ) : ZoomableUrlContent(url, description, hash, dim, uri) @Immutable @@ -150,7 +156,9 @@ class ZoomableLocalVideo( description: String? = null, dim: String? = null, isVerified: Boolean? = null, - uri: String + uri: String, + val artworkUri: String? = null, + val authorName: String? = null ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) fun figureOutMimeType(fullUrl: String): ZoomableContent { @@ -202,19 +210,27 @@ fun ZoomableContentView( when (content) { is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel) is ZoomableUrlVideo -> VideoView( - content.url, - content.description, + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, accountViewModel = accountViewModel - ) { dialogOpen = true } + ) is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel) is ZoomableLocalVideo -> content.localFile?.let { VideoView( - it.toUri().toString(), - content.description, + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + nostrUriCallback = content.uri, + onDialog = { dialogOpen = true }, accountViewModel = accountViewModel - ) { dialogOpen = true } + ) } } @@ -652,7 +668,14 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true) } else if (content is ZoomableUrlVideo) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(content.url, content.description, accountViewModel = accountViewModel, alwaysShowVideo = true) + VideoView( + videoUri = content.url, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + accountViewModel = accountViewModel, + alwaysShowVideo = true + ) } } else if (content is ZoomableLocalImage) { LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true) @@ -660,8 +683,10 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { content.localFile?.let { VideoView( - it.toUri().toString(), - content.description, + videoUri = it.toUri().toString(), + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, accountViewModel = accountViewModel, alwaysShowVideo = true ) @@ -731,3 +756,17 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) { } } } + +// Window utils +@Composable +fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window + +@Composable +fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow() + +private tailrec fun Context.getActivityWindow(): Window? = + when (this) { + is Activity -> window + is ContextWrapper -> baseContext.getActivityWindow() + else -> null + } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8901064c4..27a19c4a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -2,11 +2,8 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -56,11 +53,8 @@ fun AppNavigation( navController: NavHostController, accountViewModel: AccountViewModel, - themeViewModel: ThemeViewModel, - nextPage: String? = null + themeViewModel: ThemeViewModel ) { - var actionableNextPage by remember { mutableStateOf(nextPage) } - val scope = rememberCoroutineScope() val nav = remember { { route: String -> @@ -232,11 +226,4 @@ fun AppNavigation( }) } } - - actionableNextPage?.let { - LaunchedEffect(it) { - nav(it) - } - actionableNextPage = null - } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index c2f197b19..64ac30bff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.ui.navigation import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.navigation.NamedNavArgument +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -290,6 +292,12 @@ fun getRouteWithArguments(navController: NavHostController): String? { return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) } +fun getRouteWithArguments(navState: State): String? { + return navState.value?.let { + getRouteWithArguments(it.destination, it.arguments) + } +} + private fun getRouteWithArguments( destination: NavDestination, arguments: Bundle? diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt index b43b84dc7..dba6791bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange -import com.vitorpamplona.amethyst.ui.theme.Size15Modifier +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -300,6 +300,26 @@ fun PinIcon(modifier: Modifier, tint: Color) { ) } +@Composable +fun LyricsIcon(modifier: Modifier, tint: Color) { + Icon( + painter = painterResource(id = R.drawable.lyrics_on), + contentDescription = null, + modifier = modifier, + tint = tint + ) +} + +@Composable +fun LyricsOffIcon(modifier: Modifier, tint: Color) { + Icon( + painter = painterResource(id = R.drawable.lyrics_off), + contentDescription = null, + modifier = modifier, + tint = tint + ) +} + @Composable fun ClearTextIcon() { Icon( @@ -323,7 +343,7 @@ fun VerticalDotsIcon() { Icon( imageVector = Icons.Default.MoreVert, null, - modifier = Size15Modifier, + modifier = Size18Modifier, tint = MaterialTheme.colors.placeholderText ) } 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 0dd561b09..08e8bb6b8 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 @@ -170,7 +170,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdStartPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight @@ -1347,7 +1346,11 @@ fun RenderAppDefinition( ) if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel) + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel + ) } } else { Image( @@ -1398,7 +1401,11 @@ fun RenderAppDefinition( } if (zoomImageDialogOpen) { - ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel) + ZoomableImageDialog( + imageUrl = figureOutMimeType(it.banner!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel + ) } Spacer(Modifier.weight(1f)) @@ -1484,15 +1491,15 @@ private fun RenderHighlight( } DisplayHighlight( - quote, - author, - url, - postHex, - makeItShort, - canPreview, - backgroundColor, - accountViewModel, - nav + highlight = quote, + authorHex = author, + url = url, + postAddress = postHex, + makeItShort = makeItShort, + canPreview = canPreview, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav ) } @@ -2222,7 +2229,7 @@ private fun RenderAudioTrack( ) { val noteEvent = note.event as? AudioTrackEvent ?: return - AudioTrackHeader(noteEvent, accountViewModel, nav) + AudioTrackHeader(noteEvent, note, accountViewModel, nav) } @Composable @@ -2457,7 +2464,7 @@ private fun BoostedMark() { fontWeight = FontWeight.Bold, color = MaterialTheme.colors.placeholderText, maxLines = 1, - modifier = StdStartPadding + modifier = HalfStartPadding ) } @@ -2820,7 +2827,7 @@ fun DisplayFollowingHashtagsInPost( } firstTag?.let { - Column() { + Column(verticalArrangement = Arrangement.Center) { Row(verticalAlignment = CenterVertically) { DisplayTagList(it, nav) } @@ -3132,11 +3139,25 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) { val description = event.content val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val uri = "nostr:" + note.toNEvent() + val uri = note.toNostrUri() val newContent = if (isImage) { - ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri) + ZoomableUrlImage( + url = fullUrl, + description = description, + hash = hash, + blurhash = blurHash, + dim = dimensions, + uri = uri + ) } else { - ZoomableUrlVideo(fullUrl, description, hash, uri) + ZoomableUrlVideo( + url = fullUrl, + description = description, + hash = hash, + dim = dimensions, + uri = uri, + authorName = note.author?.toBestDisplayName() + ) } launch(Dispatchers.Main) { @@ -3196,7 +3217,7 @@ private fun RenderNIP95( if (content == null) { LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) { launch(Dispatchers.IO) { - val uri = "nostr:" + header.toNEvent() + val uri = header.toNostrUri() val localDir = note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) } val blurHash = eventHeader.blurhash() @@ -3221,7 +3242,8 @@ private fun RenderNIP95( description = description, dim = dimensions, isVerified = true, - uri = uri + uri = uri, + authorName = header.author?.toBestDisplayName() ) } @@ -3242,7 +3264,7 @@ private fun RenderNIP95( } @Composable -fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewModel, nav: (String) -> Unit) { +fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val media = remember { noteEvent.media() } val cover = remember { noteEvent.cover() } val subject = remember { noteEvent.subject() } @@ -3304,14 +3326,17 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo cover?.let { cover -> LoadThumbAndThenVideoView( videoUri = media, - description = noteEvent.subject(), + title = noteEvent.subject(), thumbUri = cover, + authorName = note.author?.toBestDisplayName(), + nostrUriCallback = "nostr:${note.toNEvent()}", accountViewModel = accountViewModel ) } ?: VideoView( videoUri = media, - description = noteEvent.subject(), + title = noteEvent.subject(), + authorName = note.author?.toBestDisplayName(), accountViewModel = accountViewModel ) } @@ -3436,8 +3461,11 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo ) { VideoView( videoUri = media, - description = subject, - accountViewModel = accountViewModel + title = subject, + artworkUri = cover, + authorName = baseNote.author?.toBestDisplayName(), + accountViewModel = accountViewModel, + nostrUriCallback = "nostr:${baseNote.toNEvent()}" ) } } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index e6387f3e6..fccdf7ba8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -22,6 +22,8 @@ fun timeAgo(mills: Long?, context: Context): String { .replace(" hr. ago", context.getString(R.string.h)) .replace(" min. ago", context.getString(R.string.m)) .replace(" days ago", context.getString(R.string.d)) + .replace(" hr ago", context.getString(R.string.h)) + .replace(" min ago", context.getString(R.string.m)) .replace("Yesterday", "1" + context.getString(R.string.d)) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 82b872d7e..b3dd03f54 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage @Composable -fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) { +fun AccountScreen( + accountStateViewModel: AccountStateViewModel, + themeViewModel: ThemeViewModel, + navController: NavHostController +) { val accountState by accountStateViewModel.accountContent.collectAsState() Column() { @@ -27,7 +32,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: factory = AccountViewModel.Factory(state.account) ) - MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage) + MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController) } is AccountState.LoggedInViewOnly -> { val accountViewModel: AccountViewModel = viewModel( @@ -35,7 +40,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: factory = AccountViewModel.Factory(state.account) ) - MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage) + MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThemeViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThemeViewModel.kt index 661d8672d..c64fe3deb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThemeViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThemeViewModel.kt @@ -1,9 +1,11 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Immutable import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +@Immutable class ThemeViewModel : ViewModel() { private val _theme = MutableLiveData(0) val theme: LiveData = _theme diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 1d7cb9141..477450708 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -377,7 +377,7 @@ fun NoteMaster( } else if (noteEvent is PeopleListEvent) { DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is AudioTrackEvent) { - AudioTrackHeader(noteEvent, accountViewModel, nav) + AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 70f91e3cd..9fb9ed30a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -607,25 +607,47 @@ private fun ShowVideoStreaming( event = it, accountViewModel = accountViewModel ) { - val streamingUrl by baseChannel.live.map { + val streamingInfo by baseChannel.live.map { val activity = it.channel as? LiveActivitiesChannel - activity?.info?.streaming() - }.distinctUntilChanged().observeAsState(baseChannel.info?.streaming()) + activity?.info + }.distinctUntilChanged().observeAsState(baseChannel.info) - streamingUrl?.let { - CheckIfUrlIsOnline(it) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = remember { Modifier.heightIn(max = 300.dp) } - ) { - val zoomableUrlVideo = remember(it) { - ZoomableUrlVideo(url = it) + streamingInfo?.let { event -> + val url = remember(streamingInfo) { + event.streaming() + } + val artworkUri = remember(streamingInfo) { + event.image() + } + val title = remember(streamingInfo) { + baseChannel.toBestDisplayName() + } + + val author = remember(streamingInfo) { + baseChannel.creatorName() + } + + url?.let { + CheckIfUrlIsOnline(url) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = remember { Modifier.heightIn(max = 300.dp) } + ) { + val zoomableUrlVideo = remember(it) { + ZoomableUrlVideo( + url = url, + description = title, + artworkUri = artworkUri, + authorName = author, + uri = event.toNostrUri() + ) + } + + ZoomableContentView( + content = zoomableUrlVideo, + accountViewModel = accountViewModel + ) } - - ZoomableContentView( - content = zoomableUrlVideo, - accountViewModel = accountViewModel - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 92262cc11..5866d1367 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -27,8 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton import com.vitorpamplona.amethyst.ui.buttons.NewImageButton @@ -61,10 +61,9 @@ fun MainScreen( accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, - startingPage: String? = null + navController: NavHostController ) { val scope = rememberCoroutineScope() - val navController = rememberNavController() val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val sheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, @@ -218,8 +217,7 @@ fun MainScreen( userReactionsStatsModel = userReactionsStatsModel, navController = navController, accountViewModel = accountViewModel, - themeViewModel = themeViewModel, - nextPage = startingPage + themeViewModel = themeViewModel ) } } 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 405846ebf..fbf983ce9 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,12 +50,16 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay +import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteComposeReportState import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.RenderRelay +import com.vitorpamplona.amethyst.ui.note.RenderReportState import com.vitorpamplona.amethyst.ui.note.ViewCountReaction +import com.vitorpamplona.amethyst.ui.note.WatchForReports import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedError @@ -68,6 +73,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun VideoScreen( @@ -213,7 +221,75 @@ fun SlidingCarousel( } ) { index -> feed.value.getOrNull(index)?.let { note -> - RenderVideoOrPictureNote(note, accountViewModel, nav) + LoadedVideoCompose(note, accountViewModel, nav) + } + } +} + +@Composable +fun LoadedVideoCompose( + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var state by remember { + mutableStateOf( + NoteComposeReportState( + isAcceptable = true, + canPreview = true, + relevantReports = persistentSetOf() + ) + ) + } + + val scope = rememberCoroutineScope() + + WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports -> + if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) { + val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports) + scope.launch(Dispatchers.Main) { + state = newState + } + } + } + + Crossfade(targetState = state) { + RenderReportState( + it, + note, + accountViewModel, + nav + ) + } +} + +@Composable +fun RenderReportState( + state: NoteComposeReportState, + note: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var showReportedNote by remember { mutableStateOf(false) } + + Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote -> + if (showHiddenNote) { + Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { + HiddenNote( + state.relevantReports, + accountViewModel, + Modifier, + false, + nav, + onClick = { showReportedNote = true } + ) + } + } else { + RenderVideoOrPictureNote( + note, + accountViewModel, + nav + ) } } } @@ -224,7 +300,7 @@ private fun RenderVideoOrPictureNote( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - Column(remember { Modifier.fillMaxSize(1f) }) { + Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) { Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { val noteEvent = remember { note.event } if (noteEvent is FileHeaderEvent) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 65504f510..1161e6c48 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.theme import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -95,8 +94,8 @@ val DividerThickness = 0.25.dp val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp) val ReactionRowHeightChat = Modifier.height(25.dp) -val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth() -val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth() +val UserNameRowHeight = Modifier.fillMaxWidth() +val UserNameMaxRowHeight = Modifier.fillMaxWidth() val Height4dpModifier = Modifier.height(4.dp) @@ -117,4 +116,5 @@ val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp) val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) +val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) diff --git a/app/src/main/res/drawable/lyrics_off.xml b/app/src/main/res/drawable/lyrics_off.xml new file mode 100644 index 000000000..763ec6fc1 --- /dev/null +++ b/app/src/main/res/drawable/lyrics_off.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/lyrics_on.xml b/app/src/main/res/drawable/lyrics_on.xml new file mode 100644 index 000000000..c4a17de8b --- /dev/null +++ b/app/src/main/res/drawable/lyrics_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 89ffb59de..713f66aba 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -23,6 +23,7 @@ Kopírovat ID poznámky Vysílání Požadovat smazání + Blockovat / Nahlásit Nahlásit spam / podvod Nahlásit zneužívání identity @@ -43,11 +44,11 @@ Nová částka v sats Přidat "odpovídá na " - "a " + " a " "v kanálu " Banner profilu - "Sleduje" - "Sledující" + " Sleduje" + " Sledující" Profil Bezpečnostní filtry Odhlásit @@ -164,6 +165,7 @@ Zobrazit nejprve v %1$s Vždy překládat do %1$s Nikdy nepřekládat z %1$s + Nostr Adresa nikdy nyní h @@ -447,4 +449,38 @@ Sledované značky Rele + + Živě + Komunita + Chaty + Schválené příspěvky + + Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a. + Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a. + + Citlivý obsah + Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah. + Nastavení + Vždy + Pouze Wi-Fi + Systém + Světlý + Tmavý + Předvolby aplikace + Jazyk + Motiv + Automaticky načítat obrázky/gif + Automaticky přehrávat videa + Automaticky zobrazit náhled URL + Načíst obrázek + + Spamovací uživatelé + + Ztlumené. Klikněte pro odztlumení + Zvuk zapnutý. Klikněte pro ztlumení + Hledat lokální a vzdálené záznamy + + Adresa Nostr byla ověřena + Ověření adresy Nostr se nezdařilo + Kontrola adresy Nostr diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7c3fd6108..dc4c14090 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,6 +24,7 @@ Senden Löschung beantragen + Blockieren / Melden Spam / Betrug melden Vortäuschung melden Expliziten Inhalt melden @@ -168,6 +169,7 @@ anz der Bedingungen ist erforderlich Zuerst in %1$s anzeigen Immer ins %1$s übersetzen Niemals aus dem %1$s übersetzen + Nostr-Adresse nie jetzt h @@ -456,4 +458,38 @@ anz der Bedingungen ist erforderlich Gefolgte Tags Relais + + Live + Community + Chats + Genehmigte Beiträge + + Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen. + Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen. + + Sensibler Inhalt + Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird. + Einstellungen + Immer + Nur WLAN + System + Hell + Dunkel + Anwendungseinstellungen + Sprache + Design + Bilder/GIFs automatisch laden + Videos automatisch abspielen + URL-Vorschau automatisch anzeigen + Bild laden + + Spammer + + Ton aus. Klicken, um Ton einzuschalten + Ton an. Klicken, um Ton auszuschalten + Lokale und entfernte Einträge durchsuchen + + Nostr-Adresse verifiziert + Nostr-Adresse konnte nicht verifiziert werden + Nostr-Adresse wird überprüft \ No newline at end of file diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 24d3eca12..e36dec13a 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -23,6 +23,7 @@ Sänd ut Begär radering + Blockera / Rapportera Rapportera Spam / Scam Rapportera identitetsstöld Rapportera explicit innehåll @@ -163,6 +164,7 @@ Visa i %1$s first Översätt alltid till %1$s Översätt aldrig från %1$s + Nostr-adress aldrig nu h @@ -444,6 +446,40 @@ Följda taggar Reläer + + Live + Gemenskap + Chattar + Godkända Inlägg + + Denna grupp har ingen beskrivning eller regler. Prata med ägaren för att lägga till en. + Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en. + + Känsligt innehåll + Lägger till varning för känsligt innehåll innan detta innehåll visas. + Inställningar + Alltid + Endast Wi-Fi + System + Ljus + Mörk + Applikationsinställningar + Språk + Tema + Ladda automatiskt bilder/gif + Spela automatiskt videor + Visa automatiskt förhandsgranskning av URL + Ladda bild + + Spammare + + Ljud avstängt. Klicka för att ta bort ljudlöst + Ljud på. Klicka för att stänga av ljudet + Sök lokala och externa poster + + Nostr-adress verifierad + Verifikation av Nostr-adress misslyckades + Kontrollerar Nostr-adress diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 7c490ffe7..a407a07dd 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -2,6 +2,7 @@ +