Merge branch 'main' into update_translations
This commit is contained in:
+3
-2
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
mediaItem = mediaItem,
|
||||||
when (event) {
|
videoUri = videoUri,
|
||||||
Lifecycle.Event.ON_PAUSE -> {
|
defaultToStart = defaultToStart,
|
||||||
exoPlayerData.exoPlayer.pause()
|
nostrUriCallback = nostrUriCallback
|
||||||
|
) { controller, keepPlaying ->
|
||||||
|
RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog)
|
||||||
}
|
}
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val lifecycle = lifecycleOwner.value.lifecycle
|
|
||||||
|
|
||||||
lifecycle.addObserver(observer)
|
|
||||||
onDispose {
|
|
||||||
lifecycle.removeObserver(observer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 ->
|
||||||
|
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<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
|
||||||
@@ -373,4 +722,46 @@ 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(
|
||||||
@@ -631,6 +652,7 @@ private fun ShowVideoStreaming(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user