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 @@
+