Merge branch 'main' into update_translations

This commit is contained in:
Vitor Pamplona
2023-07-20 20:03:17 -04:00
committed by GitHub
30 changed files with 1253 additions and 253 deletions
+3 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 256 versionCode 258
versionName "0.69.3" versionName "0.70.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -177,6 +177,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0' implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0' implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
// Local model for language identification // Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4' playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -4007,7 +4007,7 @@ class RichTextParserTest {
"Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)" "Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)"
) )
state.paragraphs.flatten().forEachIndexed { index, seg -> state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals( Assert.assertEquals(
expectedResult[index], expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
@@ -4027,7 +4027,7 @@ class RichTextParserTest {
Assert.assertTrue(state.imagesForPager.isEmpty()) Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty()) Assert.assertTrue(state.imageList.isEmpty())
Assert.assertTrue(state.customEmoji.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 @Test
@@ -4039,7 +4039,7 @@ class RichTextParserTest {
Assert.assertTrue(state.customEmoji.isEmpty()) Assert.assertTrue(state.customEmoji.isEmpty())
Assert.assertEquals( Assert.assertEquals(
"\nHi, \nhow\n\n\n are you doing? \n", "\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)" "Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
) )
state.paragraphs.flatten().forEachIndexed { index, seg -> state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals( Assert.assertEquals(
expectedResult[index], expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})" "${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
@@ -4112,7 +4112,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
private fun printStateForDebug(state: RichTextViewerState) { private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph -> state.paragraphs.forEachIndexed { index, paragraph ->
paragraph.forEach { seg -> paragraph.words.forEach { seg ->
println( println(
"\"${ "\"${
seg.javaClass.simpleName.replace( seg.javaClass.simpleName.replace(
+19
View File
@@ -27,6 +27,13 @@
<!-- To know receive notifications when the app connects/disconnects from the web --> <!-- To know receive notifications when the app connects/disconnects from the web -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Old permission to access media --> <!-- Old permission to access media -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -51,6 +58,7 @@
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout" android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst"> android:theme="@style/Theme.Amethyst">
@@ -91,6 +99,17 @@
android:screenOrientation="fullSensor" android:screenOrientation="fullSensor"
tools:replace="screenOrientation" /> tools:replace="screenOrientation" />
<service
android:name=".PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="true"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>
@@ -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<String, Long>(100)
// protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, MediaSession>()
private val cache =
object : LruCache<String, MediaSession>(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<MediaSession> {
return playingMap.values
}
}
@@ -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()
)
}
}
@@ -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)
}
}
@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.HttpClient
lateinit var cacheDataSourceFactory: CacheDataSource.Factory lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized @Synchronized
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun init(context: Context) { fun init(context: Context) {
if (!this::simpleCache.isInitialized) { if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context) exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.util.LruCache
object VideoViewedPositionCache {
val cachedPosition = LruCache<String, Long>(10)
fun add(uri: String, position: Long) {
cachedPosition.put(uri, position)
}
fun get(uri: String): Long? {
return cachedPosition.get(uri)
}
}
@@ -95,6 +95,10 @@ abstract class Channel(val idHex: String) {
return null return null
} }
open fun creatorName(): String? {
return creator?.toBestDisplayName()
}
open fun profilePicture(): String? { open fun profilePicture(): String? {
return creator?.profilePicture() return creator?.profilePicture()
} }
@@ -75,6 +75,10 @@ open class Note(val idHex: String) {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()) return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
} }
fun toNostrUri(): String {
return "nostr:${toNEvent()}"
}
open fun idDisplayNote() = idNote().toShortenHex() open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? { fun channelHex(): HexKey? {
@@ -51,6 +51,8 @@ class User(val pubkeyHex: String) {
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toNostrUri() = "nostr:${pubkeyNpub()}"
override fun toString(): String = pubkeyHex override fun toString(): String = pubkeyHex
fun toBestDisplayName(): String { fun toBestDisplayName(): String {
@@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1 import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils 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. * Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/ */
@@ -6,18 +6,28 @@ import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface 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.compose.ui.Modifier
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager 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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent 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.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting 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.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState 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.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -45,11 +59,13 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
lateinit var navController: NavHostController
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val uri = intent?.data?.toString() val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri) val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs() LocalPreferences.migrateSingleUserPrefs()
@@ -58,8 +74,11 @@ class MainActivity : AppCompatActivity() {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language) val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale) AppCompatDelegate.setApplicationLocales(appLocale)
} }
setContent { setContent {
navController = rememberNavController()
val themeViewModel: ThemeViewModel = viewModel() val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme()) themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) { AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
@@ -68,9 +87,17 @@ class MainActivity : AppCompatActivity() {
AccountStateViewModel(this@MainActivity) 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() val networkRequest = NetworkRequest.Builder()
@@ -88,6 +115,7 @@ class MainActivity : AppCompatActivity() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// starts muted every time // starts muted every time
DefaultMutedSetting.value = true DefaultMutedSetting.value = true
@@ -109,6 +137,11 @@ class MainActivity : AppCompatActivity() {
super.onPause() super.onPause()
} }
override fun onDestroy() {
super.onDestroy()
keepPlayingMutex?.stop()
}
/** /**
* Release memory when the UI becomes hidden or when system resources become low. * Release memory when the UI becomes hidden or when system resources become low.
* @param level the memory-related event that was raised. * @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() { private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use // network is available for use
override fun onAvailable(network: Network) { 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 else -> null
} }
} ?: try { } ?: try {
@@ -192,7 +192,10 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
} }
} else { } else {
postViewModel.galleryUri?.let { postViewModel.galleryUri?.let {
VideoView(it.toString(), accountViewModel = accountViewModel) VideoView(
videoUri = it.toString(),
accountViewModel = accountViewModel
)
} }
} }
} }
@@ -1,10 +1,16 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -22,12 +28,13 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -39,38 +46,41 @@ import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.DataSource import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.session.MediaController
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import coil.imageLoader import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.PlaybackClientController
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus 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.MuteIcon
import com.vitorpamplona.amethyst.ui.note.MutedIcon import com.vitorpamplona.amethyst.ui.note.MutedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime import java.util.UUID
import kotlin.time.measureTimedValue import kotlin.math.abs
public var DefaultMutedSetting = mutableStateOf(true) public var DefaultMutedSetting = mutableStateOf(true)
@Composable @Composable
fun LoadThumbAndThenVideoView( fun LoadThumbAndThenVideoView(
videoUri: String, videoUri: String,
description: String? = null, title: String? = null,
thumbUri: String, thumbUri: String,
authorName: String? = null,
nostrUriCallback: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null
) { ) {
@@ -97,95 +107,73 @@ fun LoadThumbAndThenVideoView(
if (loadingFinished.first) { if (loadingFinished.first) {
if (loadingFinished.second != null) { 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 { } 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 @Composable
fun VideoView( fun VideoView(
videoUri: String, videoUri: String,
description: String? = null, title: 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,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false alwaysShowVideo: Boolean = false
) { ) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) } val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
val context = LocalContext.current
LaunchedEffect(key1 = videoUri) { VideoViewInner(
if (exoPlayerData == null) { videoUri,
launch(Dispatchers.Default) { defaultToStart,
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build()) title,
} thumb,
} artworkUri,
} authorName,
nostrUriCallback,
exoPlayerData?.let { alwaysShowVideo,
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo) accountViewModel,
} onDialog
)
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")
} }
@Composable @Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoView1( fun VideoViewInner(
videoUri: String, videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false, defaultToStart: Boolean = false,
title: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null, artworkUri: String? = null,
authorName: String? = null,
nostrUriCallback: String? = null,
alwaysShowVideo: Boolean = false,
accountViewModel: AccountViewModel, 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 settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value 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) { if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else { } else {
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog) 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()
} }
DisposableEffect(Unit) { 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<Boolean>) -> Unit
) {
val context = LocalContext.current
val controller = remember(videoUri) {
mutableStateOf<MediaController?>(
if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) keepPlayingMutex else null
)
}
val keepPlaying = remember(videoUri) {
mutableStateOf<Boolean>(
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 -> val observer = LifecycleEventObserver { _, event ->
when (event) { if (event == Lifecycle.Event.ON_RESUME) {
Lifecycle.Event.ON_PAUSE -> { // if the controller is null, restarts the controller with a new one
exoPlayerData.exoPlayer.pause() // if the controller is not null, just continue playing what the controller was playing
} if (controller.value == null) {
else -> {} scope.launch(Dispatchers.IO) {
} PlaybackClientController.prepareController(
} UUID.randomUUID().toString(),
val lifecycle = lifecycleOwner.value.lifecycle videoUri,
nostrUriCallback,
context
) {
// checks again to make sure no other thread has created a controller.
if (controller.value == null) {
controller.value = it
lifecycle.addObserver(observer) 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 { onDispose {
lifecycle.removeObserver(observer) 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<VisibilityData>()
@Stable @Stable
data class VideoPlayer( class VisibilityData() {
val exoPlayer: ExoPlayer 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<Boolean>) -> Unit) {
val myCache = remember(videoUri) {
VisibilityData()
}
// Is the current video the closest to the center?
val active = remember(videoUri) {
mutableStateOf<Boolean>(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 @Stable
data class VideoThumb( data class VideoThumb(
@@ -260,36 +471,32 @@ data class VideoThumb(
) )
@Composable @Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @OptIn(UnstableApi::class)
private fun RenderVideoPlayer( private fun RenderVideoPlayer(
playerData: VideoPlayer, controller: MediaController,
thumbData: VideoThumb?, thumbData: VideoThumb?,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: MutableState<Boolean>, automaticallyStartPlayback: MutableState<Boolean>,
activeOnScreen: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)? onDialog: ((Boolean) -> Unit)?
) { ) {
val context = LocalContext.current val context = LocalContext.current
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
val controllerVisible = remember(controller) {
mutableStateOf(false)
}
BoxWithConstraints() { BoxWithConstraints() {
AndroidView( AndroidView(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.defaultMinSize(minHeight = 70.dp) .defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center) .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()
}
},
factory = { factory = {
PlayerView(context).apply { PlayerView(context).apply {
player = playerData.exoPlayer player = controller
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
@@ -301,18 +508,102 @@ private fun RenderVideoPlayer(
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog -> onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener { setFullscreenButtonClickListener {
playerData.exoPlayer.pause() controller.pause()
innerOnDialog(it) 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 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<Boolean>,
automaticallyStartPlayback: MutableState<Boolean>,
activeOnScreen: MutableState<Boolean>
) {
// 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 { fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
if (!isAttached) return false if (!isAttached) return false
// Window relative bounds of our compose root view that are visible on the screen // 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)) { if (!view.getGlobalVisibleRect(globalRootRect)) {
// we aren't visible at all. // we aren't visible at all.
return false return false
} }
val bounds = boundsInWindow() val bounds = boundsInWindow()
if (bounds.isEmpty) return false
// Make sure we are completely in bounds. // Make sure we are completely in bounds.
return bounds.top >= globalRootRect.top && return bounds.top >= globalRootRect.top &&
bounds.left >= globalRootRect.left && bounds.left >= globalRootRect.left &&
@@ -346,8 +640,65 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
bounds.bottom <= globalRootRect.bottom 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 @Composable
private fun MuteButton(toggle: (Boolean) -> Unit) { private fun MuteButton(
controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean,
toggle: (Boolean) -> Unit
) {
val holdOn = remember {
mutableStateOf<Boolean>(
true
)
}
LaunchedEffect(key1 = controllerVisible) {
launch(Dispatchers.Default) {
delay(2000)
holdOn.value = false
}
}
val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
AnimatedVisibility(
visible = holdOn.value || controllerVisible.value,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = VolumeBottomIconSize) { Box(modifier = VolumeBottomIconSize) {
Box( Box(
Modifier Modifier
@@ -357,8 +708,6 @@ private fun MuteButton(toggle: (Boolean) -> Unit) {
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
) )
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
IconButton( IconButton(
onClick = { onClick = {
mutedInstance.value = !mutedInstance.value mutedInstance.value = !mutedInstance.value
@@ -374,3 +723,45 @@ private fun MuteButton(toggle: (Boolean) -> Unit) {
} }
} }
} }
}
@Composable
private fun KeepPlayingButton(
keepPlayingStart: MutableState<Boolean>,
controllerVisible: MutableState<Boolean>,
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)
}
}
}
}
}
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.view.Window
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable 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.unit.isFinite
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -119,7 +123,9 @@ class ZoomableUrlVideo(
description: String? = null, description: String? = null,
hash: String? = null, hash: String? = null,
dim: 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) ) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable @Immutable
@@ -150,7 +156,9 @@ class ZoomableLocalVideo(
description: String? = null, description: String? = null,
dim: String? = null, dim: String? = null,
isVerified: Boolean? = null, isVerified: Boolean? = null,
uri: String uri: String,
val artworkUri: String? = null,
val authorName: String? = null
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri) ) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent { fun figureOutMimeType(fullUrl: String): ZoomableContent {
@@ -202,19 +210,27 @@ fun ZoomableContentView(
when (content) { when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel) is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
is ZoomableUrlVideo -> VideoView( is ZoomableUrlVideo -> VideoView(
content.url, videoUri = content.url,
content.description, title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel accountViewModel = accountViewModel
) { dialogOpen = true } )
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel) is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
is ZoomableLocalVideo -> is ZoomableLocalVideo ->
content.localFile?.let { content.localFile?.let {
VideoView( VideoView(
it.toUri().toString(), videoUri = it.toUri().toString(),
content.description, title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
nostrUriCallback = content.uri,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel accountViewModel = accountViewModel
) { dialogOpen = true } )
} }
} }
@@ -652,7 +668,14 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true) UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { 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) { } else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true) 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)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let { content.localFile?.let {
VideoView( VideoView(
it.toUri().toString(), videoUri = it.toUri().toString(),
content.description, title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
alwaysShowVideo = true 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
}
@@ -2,11 +2,8 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -56,11 +53,8 @@ fun AppNavigation(
navController: NavHostController, navController: NavHostController,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
themeViewModel: ThemeViewModel, themeViewModel: ThemeViewModel
nextPage: String? = null
) { ) {
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val nav = remember { val nav = remember {
{ route: String -> { route: String ->
@@ -232,11 +226,4 @@ fun AppNavigation(
}) })
} }
} }
actionableNextPage?.let {
LaunchedEffect(it) {
nav(it)
}
actionableNextPage = null
}
} }
@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.navigation.NamedNavArgument import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -290,6 +292,12 @@ fun getRouteWithArguments(navController: NavHostController): String? {
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)
} }
fun getRouteWithArguments(navState: State<NavBackStackEntry?>): String? {
return navState.value?.let {
getRouteWithArguments(it.destination, it.arguments)
}
}
private fun getRouteWithArguments( private fun getRouteWithArguments(
destination: NavDestination, destination: NavDestination,
arguments: Bundle? arguments: Bundle?
@@ -29,7 +29,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange 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.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText 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 @Composable
fun ClearTextIcon() { fun ClearTextIcon() {
Icon( Icon(
@@ -323,7 +343,7 @@ fun VerticalDotsIcon() {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
null, null,
modifier = Size15Modifier, modifier = Size18Modifier,
tint = MaterialTheme.colors.placeholderText tint = MaterialTheme.colors.placeholderText
) )
} }
@@ -170,7 +170,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
@@ -1347,7 +1346,11 @@ fun RenderAppDefinition(
) )
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel) ZoomableImageDialog(
imageUrl = figureOutMimeType(it.banner!!),
onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel
)
} }
} else { } else {
Image( Image(
@@ -1398,7 +1401,11 @@ fun RenderAppDefinition(
} }
if (zoomImageDialogOpen) { 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)) Spacer(Modifier.weight(1f))
@@ -1484,15 +1491,15 @@ private fun RenderHighlight(
} }
DisplayHighlight( DisplayHighlight(
quote, highlight = quote,
author, authorHex = author,
url, url = url,
postHex, postAddress = postHex,
makeItShort, makeItShort = makeItShort,
canPreview, canPreview = canPreview,
backgroundColor, backgroundColor = backgroundColor,
accountViewModel, accountViewModel = accountViewModel,
nav nav = nav
) )
} }
@@ -2222,7 +2229,7 @@ private fun RenderAudioTrack(
) { ) {
val noteEvent = note.event as? AudioTrackEvent ?: return val noteEvent = note.event as? AudioTrackEvent ?: return
AudioTrackHeader(noteEvent, accountViewModel, nav) AudioTrackHeader(noteEvent, note, accountViewModel, nav)
} }
@Composable @Composable
@@ -2457,7 +2464,7 @@ private fun BoostedMark() {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.placeholderText, color = MaterialTheme.colors.placeholderText,
maxLines = 1, maxLines = 1,
modifier = StdStartPadding modifier = HalfStartPadding
) )
} }
@@ -2820,7 +2827,7 @@ fun DisplayFollowingHashtagsInPost(
} }
firstTag?.let { firstTag?.let {
Column() { Column(verticalArrangement = Arrangement.Center) {
Row(verticalAlignment = CenterVertically) { Row(verticalAlignment = CenterVertically) {
DisplayTagList(it, nav) DisplayTagList(it, nav)
} }
@@ -3132,11 +3139,25 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
val description = event.content val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent() val uri = note.toNostrUri()
val newContent = if (isImage) { 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 { } else {
ZoomableUrlVideo(fullUrl, description, hash, uri) ZoomableUrlVideo(
url = fullUrl,
description = description,
hash = hash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName()
)
} }
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
@@ -3196,7 +3217,7 @@ private fun RenderNIP95(
if (content == null) { if (content == null) {
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) { LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val uri = "nostr:" + header.toNEvent() val uri = header.toNostrUri()
val localDir = val localDir =
note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) } note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash() val blurHash = eventHeader.blurhash()
@@ -3221,7 +3242,8 @@ private fun RenderNIP95(
description = description, description = description,
dim = dimensions, dim = dimensions,
isVerified = true, isVerified = true,
uri = uri uri = uri,
authorName = header.author?.toBestDisplayName()
) )
} }
@@ -3242,7 +3264,7 @@ private fun RenderNIP95(
} }
@Composable @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 media = remember { noteEvent.media() }
val cover = remember { noteEvent.cover() } val cover = remember { noteEvent.cover() }
val subject = remember { noteEvent.subject() } val subject = remember { noteEvent.subject() }
@@ -3304,14 +3326,17 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
cover?.let { cover -> cover?.let { cover ->
LoadThumbAndThenVideoView( LoadThumbAndThenVideoView(
videoUri = media, videoUri = media,
description = noteEvent.subject(), title = noteEvent.subject(),
thumbUri = cover, thumbUri = cover,
authorName = note.author?.toBestDisplayName(),
nostrUriCallback = "nostr:${note.toNEvent()}",
accountViewModel = accountViewModel accountViewModel = accountViewModel
) )
} }
?: VideoView( ?: VideoView(
videoUri = media, videoUri = media,
description = noteEvent.subject(), title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(),
accountViewModel = accountViewModel accountViewModel = accountViewModel
) )
} }
@@ -3436,8 +3461,11 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
) { ) {
VideoView( VideoView(
videoUri = media, videoUri = media,
description = subject, title = subject,
accountViewModel = accountViewModel artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(),
accountViewModel = accountViewModel,
nostrUriCallback = "nostr:${baseNote.toNEvent()}"
) )
} }
} else { } else {
@@ -22,6 +22,8 @@ fun timeAgo(mills: Long?, context: Context): String {
.replace(" hr. ago", context.getString(R.string.h)) .replace(" hr. ago", context.getString(R.string.h))
.replace(" min. ago", context.getString(R.string.m)) .replace(" min. ago", context.getString(R.string.m))
.replace(" days ago", context.getString(R.string.d)) .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)) .replace("Yesterday", "1" + context.getString(R.string.d))
} }
@@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
@Composable @Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) { fun AccountScreen(
accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel,
navController: NavHostController
) {
val accountState by accountStateViewModel.accountContent.collectAsState() val accountState by accountStateViewModel.accountContent.collectAsState()
Column() { Column() {
@@ -27,7 +32,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
factory = AccountViewModel.Factory(state.account) factory = AccountViewModel.Factory(state.account)
) )
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage) MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
} }
is AccountState.LoggedInViewOnly -> { is AccountState.LoggedInViewOnly -> {
val accountViewModel: AccountViewModel = viewModel( val accountViewModel: AccountViewModel = viewModel(
@@ -35,7 +40,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
factory = AccountViewModel.Factory(state.account) factory = AccountViewModel.Factory(state.account)
) )
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage) MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
} }
} }
} }
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Immutable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@Immutable
class ThemeViewModel : ViewModel() { class ThemeViewModel : ViewModel() {
private val _theme = MutableLiveData(0) private val _theme = MutableLiveData(0)
val theme: LiveData<Int> = _theme val theme: LiveData<Int> = _theme
@@ -377,7 +377,7 @@ fun NoteMaster(
} else if (noteEvent is PeopleListEvent) { } else if (noteEvent is PeopleListEvent) {
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav) DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AudioTrackEvent) { } else if (noteEvent is AudioTrackEvent) {
AudioTrackHeader(noteEvent, accountViewModel, nav) AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav)
} else if (noteEvent is CommunityPostApprovalEvent) { } else if (noteEvent is CommunityPostApprovalEvent) {
RenderPostApproval( RenderPostApproval(
baseNote, baseNote,
@@ -607,19 +607,40 @@ private fun ShowVideoStreaming(
event = it, event = it,
accountViewModel = accountViewModel accountViewModel = accountViewModel
) { ) {
val streamingUrl by baseChannel.live.map { val streamingInfo by baseChannel.live.map {
val activity = it.channel as? LiveActivitiesChannel val activity = it.channel as? LiveActivitiesChannel
activity?.info?.streaming() activity?.info
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming()) }.distinctUntilChanged().observeAsState(baseChannel.info)
streamingUrl?.let { streamingInfo?.let { event ->
CheckIfUrlIsOnline(it) { 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) } modifier = remember { Modifier.heightIn(max = 300.dp) }
) { ) {
val zoomableUrlVideo = remember(it) { val zoomableUrlVideo = remember(it) {
ZoomableUrlVideo(url = it) ZoomableUrlVideo(
url = url,
description = title,
artworkUri = artworkUri,
authorName = author,
uri = event.toNostrUri()
)
} }
ZoomableContentView( ZoomableContentView(
@@ -632,6 +653,7 @@ private fun ShowVideoStreaming(
} }
} }
} }
}
@Composable @Composable
private fun ShortChannelHeader( private fun ShortChannelHeader(
@@ -27,8 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
@@ -61,10 +61,9 @@ fun MainScreen(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel, accountStateViewModel: AccountStateViewModel,
themeViewModel: ThemeViewModel, themeViewModel: ThemeViewModel,
startingPage: String? = null navController: NavHostController
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed)) val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden, initialValue = ModalBottomSheetValue.Hidden,
@@ -218,8 +217,7 @@ fun MainScreen(
userReactionsStatsModel = userReactionsStatsModel, userReactionsStatsModel = userReactionsStatsModel,
navController = navController, navController = navController,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
themeViewModel = themeViewModel, themeViewModel = themeViewModel
nextPage = startingPage
) )
} }
} }
@@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay 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.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture 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.NoteDropDownMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.RenderRelay 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.ViewCountReaction
import com.vitorpamplona.amethyst.ui.note.WatchForReports
import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError 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.onBackgroundColorFilter
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun VideoScreen( fun VideoScreen(
@@ -213,7 +221,75 @@ fun SlidingCarousel(
} }
) { index -> ) { index ->
feed.value.getOrNull(index)?.let { note -> 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, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
Column(remember { Modifier.fillMaxSize(1f) }) { Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) {
val noteEvent = remember { note.event } val noteEvent = remember { note.event }
if (noteEvent is FileHeaderEvent) { if (noteEvent is FileHeaderEvent) {
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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 ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
val ReactionRowHeightChat = Modifier.height(25.dp) val ReactionRowHeightChat = Modifier.height(25.dp)
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth() val UserNameRowHeight = Modifier.fillMaxWidth()
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth() val UserNameMaxRowHeight = Modifier.fillMaxWidth()
val Height4dpModifier = Modifier.height(4.dp) 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 ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
val VolumeBottomIconSize = Modifier.size(70.dp).padding(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) val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
+18
View File
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M810,40L810,261.02C800,257.68 790.83,255.01 782.5,253.01C774.17,251.01 766.64,250 759.92,250C729.39,250 703.44,260.66 682.07,281.99C660.7,303.33 650,329.29 650,359.88C650,390.47 660.66,416.48 681.99,437.89C703.33,459.3 729.29,470 759.88,470C790.47,470 816.48,459.32 837.89,437.93C859.3,416.54 870,390.56 870,360L870,100L960,100L960,40L810,40zM191.25,80L250.08,140L620,140L620,246.99C628.19,236.99 637.42,228 647.66,220C657.89,212 668.67,204.98 680,198.98L680,140C680,123.5 674.13,109.37 662.38,97.62C650.63,85.87 636.5,80 620,80L191.25,80zM80,195.08L80,880L240,720L594.84,720L535.98,660L140,660L140,256.25L80,195.08zM357.97,250L416.84,310L520,310L520,250L357.97,250zM240,370L240,430L310.43,430L251.56,370L240,370zM475.66,370L520,415.2L520,370L475.66,370zM620,473.01L620,517.15L680,578.32L680,521.02C668.67,515.02 657.89,508 647.66,500C637.42,492 628.19,483.01 620,473.01zM240,490L240,550L400,550L400,521.33L369.26,490L240,490z"/>
<path
android:pathData="M61.23,61.69 L873.53,889.89"
android:strokeWidth="160"
android:fillColor="#000000"/>
<path
android:pathData="M64.34,61.69 L876.65,889.89"
android:strokeWidth="60"
android:fillColor="#000000"
android:strokeColor="#000000"/>
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M140,660v-520,520ZM80,880v-740q0,-24.75 17.63,-42.38T140,80h480q24.75,0 42.38,17.63T680,140v59q-17,9 -32.36,21T620,247v-107L140,140v520h480v-187q12.29,15 27.64,27Q663,512 680,521v139q0,24.75 -17.63,42.38T620,720L240,720L80,880ZM240,550h160v-60L240,490v60ZM759.88,470Q714,470 682,437.88q-32,-32.12 -32,-78Q650,314 682.06,282q32.06,-32 77.86,-32 10.08,0 22.58,3t27.5,8v-221h150v60h-90v260q0,45.83 -32.12,77.92 -32.12,32.08 -78,32.08ZM240,430h280v-60L240,370v60ZM240,310h280v-60L240,250v60Z"/>
</vector>