mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 14:54:36 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d2a712c5 | ||
|
|
2664292993 | ||
|
|
b9813c2c8f | ||
|
|
0c00e6512f | ||
|
|
cf31bfe5ea | ||
|
|
5cdceb5194 | ||
|
|
2c82e6c447 | ||
|
|
f56f3b6105 | ||
|
|
9420ddc5c7 | ||
|
|
ade1d83817 | ||
|
|
9a517380a0 | ||
|
|
6fbb59ddac | ||
|
|
fe4a4b6fad | ||
|
|
8dd5705f02 | ||
|
|
6723225885 | ||
|
|
fb19454259 | ||
|
|
c6f63a13d3 | ||
|
|
77057fcac5 | ||
|
|
676d5e9b88 | ||
|
|
d6dda0053a | ||
|
|
d33e62367e | ||
|
|
47d7a53c1c | ||
|
|
6fcf472638 | ||
|
|
e2f5a1a0e4 | ||
|
|
8ea25e735d | ||
|
|
a9d05070ed | ||
|
|
d78ec6b02a | ||
|
|
8971b4e190 | ||
|
|
dea0adaea4 | ||
|
|
5de4fbcbe7 | ||
|
|
2330e086ea | ||
|
|
11d51b3854 | ||
|
|
008469adbc | ||
|
|
bbe26c7a30 | ||
|
|
c2b9bb413d | ||
|
|
5c1c4f8db2 | ||
|
|
b46bb7ecc5 | ||
|
|
2fed65db8d | ||
|
|
66cc4b5526 | ||
|
|
91488f0350 | ||
|
|
f2429e011e | ||
|
|
1374f36b1f | ||
|
|
d185e65b3f | ||
|
|
64bf17b1a0 | ||
|
|
93b50468fd | ||
|
|
d730eeb9a1 | ||
|
|
0d63441611 | ||
|
|
f0ecf382e0 | ||
|
|
d0dee84031 |
+6
-5
@@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 255
|
||||
versionName "0.69.2"
|
||||
versionCode 261
|
||||
versionName "0.70.4"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -110,7 +110,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
implementation "net.engawapg.lib:zoomable:1.4.3"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
@@ -177,6 +177,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
|
||||
implementation 'androidx.media3:media3-ui:1.1.0'
|
||||
implementation 'androidx.media3:media3-session:1.1.0'
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
@@ -198,7 +199,7 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
|
||||
// Video compression lib
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
|
||||
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
|
||||
// Image compression lib
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
|
||||
@@ -4007,7 +4007,7 @@ class RichTextParserTest {
|
||||
"Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@@ -4027,7 +4027,7 @@ class RichTextParserTest {
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.firstOrNull()?.segmentText)
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -4039,7 +4039,7 @@ class RichTextParserTest {
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals(
|
||||
"\nHi, \nhow\n\n\n are you doing? \n",
|
||||
state.paragraphs.joinToString("\n") { it.joinToString(" ") { it.segmentText } }
|
||||
state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4102,7 +4102,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@@ -4112,7 +4112,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
|
||||
private fun printStateForDebug(state: RichTextViewerState) {
|
||||
state.paragraphs.forEachIndexed { index, paragraph ->
|
||||
paragraph.forEach { seg ->
|
||||
paragraph.words.forEach { seg ->
|
||||
println(
|
||||
"\"${
|
||||
seg.javaClass.simpleName.replace(
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
<!-- To know receive notifications when the app connects/disconnects from the web -->
|
||||
<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 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -51,6 +58,7 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
@@ -91,6 +99,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,133 @@
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
private val cachedPositions: VideoViewedPositionCache
|
||||
) {
|
||||
// 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
|
||||
}
|
||||
|
||||
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) {
|
||||
player.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
player.setWakeMode(C.WAKE_MODE_NONE)
|
||||
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.add(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.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache.put(id, mediaSession)
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun releaseAppPlayers() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
cache.evictAll()
|
||||
playingMap.forEach {
|
||||
it.value.player.release()
|
||||
it.value.release()
|
||||
}
|
||||
playingMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
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,124 @@
|
||||
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.MediaSource
|
||||
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 var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerHls: MultiPlayerPlaybackManager? = null
|
||||
private var managerProgressive: MultiPlayerPlaybackManager? = null
|
||||
private var managerLocal: MultiPlayerPlaybackManager? = null
|
||||
|
||||
fun newHslDataSource(): MediaSource.Factory {
|
||||
return HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(applicationContext, HttpClient.getHttpClient()))
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add {
|
||||
val toDestroyHls = managerHls
|
||||
val toDestroyProgressive = managerProgressive
|
||||
|
||||
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
|
||||
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
|
||||
|
||||
toDestroyHls?.releaseAppPlayers()
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(applicationContext).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)
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,6 @@ object ServiceManager {
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@UnstableApi object VideoCache {
|
||||
|
||||
@@ -21,33 +21,34 @@ import com.vitorpamplona.amethyst.service.HttpClient
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
private fun init(context: Context, client: OkHttpClient) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
simpleCache = SimpleCache(
|
||||
context.cacheDir,
|
||||
leastRecentlyUsedCacheEvictor,
|
||||
exoDatabaseProvider
|
||||
)
|
||||
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
renewCacheFactory(client)
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(client: OkHttpClient) {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client)
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
init(context, client)
|
||||
}
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
class VideoViewedPositionCache {
|
||||
val cachedPosition = LruCache<String, Long>(100)
|
||||
|
||||
fun add(uri: String, position: Long) {
|
||||
cachedPosition.put(uri, position)
|
||||
}
|
||||
|
||||
fun get(uri: String): Long? {
|
||||
return cachedPosition.get(uri)
|
||||
}
|
||||
}
|
||||
@@ -628,19 +628,19 @@ class Account(
|
||||
return Pair(data, signedEvent)
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data)
|
||||
Client.send(data, relayList = relayList)
|
||||
LocalCache.consume(data, null)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendHeader(headerInfo: FileHeader): Note? {
|
||||
fun sendHeader(headerInfo: FileHeader, relayList: List<Relay>? = null): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val signedEvent = FileHeaderEvent.create(
|
||||
@@ -655,7 +655,7 @@ class Account(
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
@@ -671,7 +671,8 @@ class Account(
|
||||
zapRaiserAmount: Long? = null,
|
||||
replyingTo: String?,
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>
|
||||
directMentions: Set<HexKey>,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -694,7 +695,7 @@ class Account(
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
@@ -709,7 +710,8 @@ class Account(
|
||||
closedAt: Int?,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<Relay>? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -733,7 +735,7 @@ class Account(
|
||||
zapRaiserAmount = zapRaiserAmount
|
||||
)
|
||||
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
|
||||
Client.send(signedEvent)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
@@ -1547,7 +1549,9 @@ class Account(
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ abstract class Channel(val idHex: String) {
|
||||
return null
|
||||
}
|
||||
|
||||
open fun creatorName(): String? {
|
||||
return creator?.toBestDisplayName()
|
||||
}
|
||||
|
||||
open fun profilePicture(): String? {
|
||||
return creator?.profilePicture()
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ open class Note(val idHex: String) {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNEvent()}"
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
@@ -567,8 +571,15 @@ open class Note(val idHex: String) {
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
if (event == null) return false
|
||||
|
||||
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
|
||||
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isSensitive = event?.isSensitive() ?: false
|
||||
return accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
return isBoostedNoteHidden ||
|
||||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
fun toNostrUri() = "nostr:${pubkeyNpub()}"
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
|
||||
@@ -5,19 +5,29 @@ import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach {
|
||||
it()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
this.internalProxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val seconds = if (internalProxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.proxy(internalProxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
@@ -25,7 +35,7 @@ object HttpClient {
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
return internalProxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import fr.acinq.secp256k1.Secp256k1
|
||||
import nostr.postr.Utils
|
||||
@@ -132,6 +133,18 @@ open class Event(
|
||||
}
|
||||
}
|
||||
|
||||
open fun toNIP19(): String {
|
||||
return if (this is AddressableEvent) {
|
||||
ATag(kind, pubKey, dTag(), null).toNAddr()
|
||||
} else {
|
||||
Nip19.createNEvent(id, pubKey, kind, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun toNostrUri(): String {
|
||||
return "nostr:${toNIP19()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
|
||||
@@ -73,10 +73,18 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
fun send(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<Relay>? = null,
|
||||
onDone: (() -> Unit)? = null
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relay == null) {
|
||||
if (relayList != null) {
|
||||
RelayPool.sendToSelectedRelays(relayList, signedEvent)
|
||||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
|
||||
|
||||
@@ -62,6 +62,12 @@ object RelayPool : Relay.Listener {
|
||||
relays.forEach { it.sendFilterOnlyIfDisconnected() }
|
||||
}
|
||||
|
||||
fun sendToSelectedRelays(list: List<Relay>, signedEvent: EventInterface) {
|
||||
list.forEach { relay ->
|
||||
relays.filter { it.url == relay.url }.forEach { it.send(signedEvent) }
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
relays.forEach { it.send(signedEvent) }
|
||||
}
|
||||
|
||||
@@ -6,18 +6,28 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
@@ -25,13 +35,17 @@ import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.navigation.getRouteWithArguments
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
@@ -45,11 +59,13 @@ import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
lateinit var navController: NavHostController
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val uri = intent?.data?.toString()
|
||||
|
||||
val startingPage = uriToRoute(uri)
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
@@ -58,8 +74,11 @@ class MainActivity : AppCompatActivity() {
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
|
||||
AppCompatDelegate.setApplicationLocales(appLocale)
|
||||
}
|
||||
|
||||
setContent {
|
||||
navController = rememberNavController()
|
||||
val themeViewModel: ThemeViewModel = viewModel()
|
||||
|
||||
themeViewModel.onChange(LocalPreferences.getTheme())
|
||||
AmethystTheme(themeViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
@@ -68,9 +87,17 @@ class MainActivity : AppCompatActivity() {
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, themeViewModel, startingPage)
|
||||
AccountScreen(accountStateViewModel, themeViewModel, navController)
|
||||
}
|
||||
}
|
||||
|
||||
var actionableNextPage by remember { mutableStateOf(startingPage) }
|
||||
actionableNextPage?.let {
|
||||
LaunchedEffect(it) {
|
||||
navController.navigate(it)
|
||||
}
|
||||
actionableNextPage = null
|
||||
}
|
||||
}
|
||||
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
@@ -88,6 +115,7 @@ class MainActivity : AppCompatActivity() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// starts muted every time
|
||||
DefaultMutedSetting.value = true
|
||||
|
||||
@@ -109,6 +137,11 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
keepPlayingMutex?.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Release memory when the UI becomes hidden or when system resources become low.
|
||||
* @param level the memory-related event that was raised.
|
||||
@@ -121,6 +154,39 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
val uri = intent?.data?.toString()
|
||||
val startingPage = uriToRoute(uri)
|
||||
|
||||
startingPage?.let { route ->
|
||||
val currentRoute = getRouteWithArguments(navController)
|
||||
if (!isSameRoute(currentRoute, route)) {
|
||||
navController.navigate(route) {
|
||||
popUpTo(Route.Home.route)
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean {
|
||||
if (currentRoute == null) return false
|
||||
|
||||
if (currentRoute == newRoute) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) {
|
||||
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
// network is available for use
|
||||
override fun onAvailable(network: Network) {
|
||||
@@ -196,7 +262,14 @@ fun uriToRoute(uri: String?): String? {
|
||||
}
|
||||
}
|
||||
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS ->
|
||||
if (nip19.kind == CommunityDefinitionEvent.kind) {
|
||||
"Community/${nip19.hex}"
|
||||
} else if (nip19.kind == LiveActivitiesEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} ?: try {
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -56,7 +57,7 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context) {
|
||||
fun upload(context: Context, relayList: List<Relay>? = null) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
@@ -78,7 +79,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Loading"
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
|
||||
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
@@ -98,7 +99,7 @@ open class NewMediaModel : ViewModel() {
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
|
||||
createNIP94Record(imageUrl, mimeType, description, sensitiveContent, relayList = relayList)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
@@ -138,7 +139,7 @@ open class NewMediaModel : ViewModel() {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
@@ -162,7 +163,7 @@ open class NewMediaModel : ViewModel() {
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it)
|
||||
account?.sendHeader(it, relayList)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
@@ -191,7 +192,7 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
uploadingPercentage.value = 0.30f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
@@ -210,7 +211,7 @@ open class NewMediaModel : ViewModel() {
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
account?.sendNip95(nip95.first, nip95.second, relayList)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
|
||||
@@ -7,17 +7,21 @@ import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
@@ -33,6 +37,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -70,6 +75,17 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
}
|
||||
}
|
||||
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
@@ -82,6 +98,20 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
onPost = {
|
||||
relayList = it
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.fillMaxWidth()
|
||||
@@ -97,10 +127,26 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
onClose()
|
||||
})
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = {
|
||||
showRelaysDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.upload(context, relayList)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
@@ -192,7 +238,10 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it.toString(), accountViewModel = accountViewModel)
|
||||
VideoView(
|
||||
videoUri = it.toString(),
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
@@ -144,6 +154,20 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
if (showRelaysDialog) {
|
||||
RelaySelectionDialog(
|
||||
list = relayList,
|
||||
onClose = {
|
||||
showRelaysDialog = false
|
||||
},
|
||||
onPost = {
|
||||
relayList = it
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -165,10 +189,25 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
onClose()
|
||||
})
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
onClick = {
|
||||
showRelaysDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost()
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
@@ -265,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
onAdd = { description, server, sensitiveContent ->
|
||||
postViewModel.upload(url, description, sensitiveContent, server, context)
|
||||
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
|
||||
account.changeDefaultFileServer(server)
|
||||
},
|
||||
onCancel = {
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -128,7 +129,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text)
|
||||
tagger.run()
|
||||
|
||||
@@ -145,7 +146,20 @@ open class NewPostViewModel() : ViewModel() {
|
||||
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
|
||||
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
account?.sendPoll(
|
||||
tagger.message,
|
||||
tagger.replyTos,
|
||||
tagger.mentions,
|
||||
pollOptions,
|
||||
valueMaximum,
|
||||
valueMinimum,
|
||||
consensusThreshold,
|
||||
closedAt,
|
||||
zapReceiver,
|
||||
wantsToMarkAsSensitive,
|
||||
localZapRaiserAmount,
|
||||
relayList
|
||||
)
|
||||
} else if (originalNote?.channelHex() != null) {
|
||||
if (originalNote is AddressableEvent && originalNote?.address() != null) {
|
||||
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
|
||||
@@ -172,14 +186,15 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions
|
||||
directMentions = tagger.directMentions,
|
||||
relayList = relayList
|
||||
)
|
||||
}
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context) {
|
||||
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
@@ -194,7 +209,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server == ServersAvailable.NIP95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
|
||||
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
|
||||
}
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
@@ -370,7 +385,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
FileHeader.prepare(
|
||||
@@ -379,7 +394,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
description,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
val note = account?.sendHeader(it, relayList = relayList)
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
@@ -401,7 +416,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
@@ -411,7 +426,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
val isSelected: Boolean
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RelaySelectionDialog(
|
||||
list: List<Relay>,
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: List<Relay>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val relayList = accountViewModel.account.activeRelays()?.filter {
|
||||
it.write
|
||||
}?.map {
|
||||
it
|
||||
} ?: accountViewModel.account.convertLocalRelays().filter {
|
||||
it.write
|
||||
}
|
||||
|
||||
var relays by remember {
|
||||
mutableStateOf(
|
||||
relayList.map {
|
||||
RelayList(
|
||||
it,
|
||||
list.any { relay -> it.url == relay.url }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
RelayInformationDialog(
|
||||
onClose = {
|
||||
relayInfo = null
|
||||
},
|
||||
relayInfo = relayInfo!!,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(
|
||||
onCancel = {
|
||||
onClose()
|
||||
}
|
||||
)
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
val selectedRelays = relays.filter { it.isSelected }
|
||||
if (selectedRelays.isEmpty()) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@PostButton
|
||||
}
|
||||
onPost(selectedRelays.map { it.relay })
|
||||
onClose()
|
||||
},
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
) {
|
||||
itemsIndexed(
|
||||
relays,
|
||||
key = { _, item -> item.relay.url }
|
||||
) { index, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
relays = relays.mapIndexed { j, item ->
|
||||
if (index == j) {
|
||||
item.copy(isSelected = !item.isSelected)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
loadRelayInfo(item.relay.url, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
item.relay.url
|
||||
.removePrefix("ws://")
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/")
|
||||
)
|
||||
Switch(
|
||||
checked = item.isSelected,
|
||||
onCheckedChange = {
|
||||
relays = relays.mapIndexed { j, item ->
|
||||
if (index == j) {
|
||||
item.copy(isSelected = !item.isSelected)
|
||||
} else { item }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,10 @@ class MediaCompressor {
|
||||
// videoName = "compressed_video" // => required name
|
||||
// ),
|
||||
// OR AND NOT BOTH
|
||||
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(
|
||||
videoName = UUID.randomUUID().toString() // => required name
|
||||
),
|
||||
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
|
||||
configureWith = Configuration(
|
||||
quality = VideoQuality.LOW
|
||||
quality = VideoQuality.LOW,
|
||||
videoNames = listOf(UUID.randomUUID().toString()) // => required name
|
||||
),
|
||||
listener = object : CompressionListener {
|
||||
override fun onProgress(index: Int, percent: Float) {
|
||||
|
||||
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
@@ -285,7 +286,9 @@ private fun ZoomableContentView(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
state.imagesForPager[word]?.let {
|
||||
ZoomableContentView(it, state.imageList, accountViewModel)
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(it, state.imageList, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -10,7 +9,6 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -22,8 +20,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
@@ -46,14 +43,10 @@ fun UrlPreviewCard(
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
modifier = MaterialTheme.colors.innerPostModifier
|
||||
.clickable {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@@ -22,7 +28,7 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -39,38 +45,41 @@ import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MutedIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
public var DefaultMutedSetting = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
title: String? = null,
|
||||
thumbUri: String,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
@@ -97,95 +106,73 @@ fun LoadThumbAndThenVideoView(
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog)
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog
|
||||
)
|
||||
} else {
|
||||
VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog)
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
title = title,
|
||||
thumb = null,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo)
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView1(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false
|
||||
) {
|
||||
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
|
||||
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
val context = LocalContext.current
|
||||
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
|
||||
|
||||
LaunchedEffect(key1 = videoUri) {
|
||||
if (exoPlayerData == null) {
|
||||
launch(Dispatchers.Default) {
|
||||
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exoPlayerData?.let {
|
||||
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayerData?.exoPlayer?.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
exoPlayerData: VideoPlayer,
|
||||
defaultToStart: Boolean = false,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false
|
||||
) {
|
||||
val (_, elapsed) = measureTimedValue {
|
||||
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
|
||||
VideoViewInner(
|
||||
videoUri,
|
||||
defaultToStart,
|
||||
title,
|
||||
thumb,
|
||||
artworkUri,
|
||||
authorName,
|
||||
nostrUriCallback,
|
||||
alwaysShowVideo,
|
||||
accountViewModel,
|
||||
onDialog
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun VideoView1(
|
||||
fun VideoViewInner(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
exoPlayerData: VideoPlayer,
|
||||
defaultToStart: Boolean = false,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
|
||||
|
||||
val settings = accountViewModel.account.settings
|
||||
val isMobile = ConnectivityStatus.isOnMobileData.value
|
||||
|
||||
@@ -201,58 +188,281 @@ fun VideoView1(
|
||||
)
|
||||
}
|
||||
|
||||
exoPlayerData.exoPlayer.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = if (defaultToStart) 0f else 1f
|
||||
if (videoUri.startsWith("file")) {
|
||||
setMediaItem(media)
|
||||
} else if (videoUri.endsWith("m3u8")) {
|
||||
// Should not use cache.
|
||||
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
setMediaSource(
|
||||
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayerData.exoPlayer.pause()
|
||||
}
|
||||
else -> {}
|
||||
VideoPlayerMutex(videoUri) { activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
val lifecycle = lifecycleOwner.value.lifecycle
|
||||
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
nostrUriCallback = nostrUriCallback
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
mediaItem: MediaItem,
|
||||
videoUri: String,
|
||||
defaultToStart: Boolean = false,
|
||||
nostrUriCallback: String? = null,
|
||||
inner: @Composable (controller: MediaController, keepPlaying: MutableState<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
|
||||
data class VideoPlayer(
|
||||
val exoPlayer: ExoPlayer
|
||||
)
|
||||
class VisibilityData() {
|
||||
var distanceToCenter: Float? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* This function selects only one Video to be active. The video that is closest to the center of
|
||||
* the screen wins the mutex.
|
||||
*/
|
||||
@Composable
|
||||
fun VideoPlayerMutex(videoUri: String, inner: @Composable (MutableState<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
|
||||
data class VideoThumb(
|
||||
@@ -260,36 +470,32 @@ data class VideoThumb(
|
||||
)
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun RenderVideoPlayer(
|
||||
playerData: VideoPlayer,
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: MutableState<Boolean>,
|
||||
activeOnScreen: MutableState<Boolean>,
|
||||
onDialog: ((Boolean) -> Unit)?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
|
||||
|
||||
val controllerVisible = remember(controller) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
playerData.exoPlayer.stop()
|
||||
}
|
||||
if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) {
|
||||
playerData.exoPlayer.pause()
|
||||
} else if (visible && !playerData.exoPlayer.isPlaying) {
|
||||
playerData.exoPlayer.play()
|
||||
} else if (!visible && playerData.exoPlayer.isPlaying) {
|
||||
playerData.exoPlayer.pause()
|
||||
}
|
||||
},
|
||||
.align(Alignment.Center),
|
||||
factory = {
|
||||
PlayerView(context).apply {
|
||||
player = playerData.exoPlayer
|
||||
player = controller
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
@@ -301,18 +507,102 @@ private fun RenderVideoPlayer(
|
||||
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
playerData.exoPlayer.pause()
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener {
|
||||
controllerVisible.value = it == View.VISIBLE
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton() { mute: Boolean ->
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
|
||||
MuteButton(controllerVisible, startingMuteState) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DefaultMutedSetting.value = mute
|
||||
|
||||
playerData.exoPlayer.volume = if (mute) 0f else 1f
|
||||
// if the user unmutes a video and it's not the current playing, switches to that one.
|
||||
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
KeepPlayingButton(keepPlaying, controllerVisible, Modifier.align(Alignment.TopEnd)) { newKeepPlaying: Boolean ->
|
||||
// If something else is playing and the user marks this video to keep playing, stops the other one.
|
||||
if (newKeepPlaying) {
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
keepPlayingMutex?.stop()
|
||||
}
|
||||
keepPlayingMutex = controller
|
||||
} else {
|
||||
if (keepPlayingMutex == controller) {
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
}
|
||||
|
||||
keepPlaying.value = newKeepPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControlWhenPlayerIsActive(
|
||||
controller: Player,
|
||||
keepPlaying: MutableState<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 +623,15 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
|
||||
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
if (!isAttached) return false
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = android.graphics.Rect()
|
||||
val globalRootRect = Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return false
|
||||
}
|
||||
val bounds = boundsInWindow()
|
||||
|
||||
if (bounds.isEmpty) return false
|
||||
|
||||
// Make sure we are completely in bounds.
|
||||
return bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
@@ -346,30 +639,127 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
}
|
||||
|
||||
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view))
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
if (!isAttached) return null
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return null
|
||||
}
|
||||
|
||||
val bounds = boundsInWindow()
|
||||
|
||||
if (bounds.isEmpty) return null
|
||||
|
||||
// Make sure we are completely in bounds.
|
||||
if (bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
bounds.right <= globalRootRect.right &&
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
) {
|
||||
return abs(((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(toggle: (Boolean) -> Unit) {
|
||||
Box(modifier = VolumeBottomIconSize) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
toggle: (Boolean) -> Unit
|
||||
) {
|
||||
val holdOn = remember {
|
||||
mutableStateOf<Boolean>(
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
LaunchedEffect(key1 = controllerVisible) {
|
||||
launch(Dispatchers.Default) {
|
||||
delay(2000)
|
||||
holdOn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
mutedInstance.value = !mutedInstance.value
|
||||
toggle(mutedInstance.value)
|
||||
},
|
||||
modifier = Size50Modifier
|
||||
) {
|
||||
if (mutedInstance.value) {
|
||||
MutedIcon()
|
||||
} else {
|
||||
MuteIcon()
|
||||
val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Box(modifier = VolumeBottomIconSize) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
mutedInstance.value = !mutedInstance.value
|
||||
toggle(mutedInstance.value)
|
||||
},
|
||||
modifier = Size50Modifier
|
||||
) {
|
||||
if (mutedInstance.value) {
|
||||
MutedIcon()
|
||||
} else {
|
||||
MuteIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeepPlayingButton(
|
||||
keepPlayingStart: MutableState<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, MaterialTheme.colors.onBackground)
|
||||
} else {
|
||||
LyricsOffIcon(Size22Modifier, MaterialTheme.colors.onBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -56,6 +59,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.net.toUri
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
@@ -119,7 +123,9 @@ class ZoomableUrlVideo(
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
uri: String? = null,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
@@ -150,7 +156,9 @@ class ZoomableLocalVideo(
|
||||
description: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
uri: String,
|
||||
val artworkUri: String? = null,
|
||||
val authorName: String? = null
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
@@ -202,19 +210,27 @@ fun ZoomableContentView(
|
||||
when (content) {
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
|
||||
is ZoomableUrlVideo -> VideoView(
|
||||
content.url,
|
||||
content.description,
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel
|
||||
) { dialogOpen = true }
|
||||
)
|
||||
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
|
||||
is ZoomableLocalVideo ->
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
it.toUri().toString(),
|
||||
content.description,
|
||||
videoUri = it.toUri().toString(),
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
accountViewModel = accountViewModel
|
||||
) { dialogOpen = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,7 +668,14 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou
|
||||
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.url, content.description, accountViewModel = accountViewModel, alwaysShowVideo = true)
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
|
||||
@@ -660,8 +683,10 @@ private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: Accou
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
it.toUri().toString(),
|
||||
content.description,
|
||||
videoUri = it.toUri().toString(),
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
)
|
||||
@@ -731,3 +756,17 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Window utils
|
||||
@Composable
|
||||
fun getDialogWindow(): Window? = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
|
||||
@Composable
|
||||
fun getActivityWindow(): Window? = LocalView.current.context.getActivityWindow()
|
||||
|
||||
private tailrec fun Context.getActivityWindow(): Window? =
|
||||
when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.getActivityWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -56,11 +53,8 @@ fun AppNavigation(
|
||||
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
themeViewModel: ThemeViewModel,
|
||||
nextPage: String? = null
|
||||
themeViewModel: ThemeViewModel
|
||||
) {
|
||||
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember {
|
||||
{ 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 androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
@@ -290,6 +292,12 @@ fun getRouteWithArguments(navController: NavHostController): String? {
|
||||
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)
|
||||
}
|
||||
|
||||
fun getRouteWithArguments(navState: State<NavBackStackEntry?>): String? {
|
||||
return navState.value?.let {
|
||||
getRouteWithArguments(it.destination, it.arguments)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRouteWithArguments(
|
||||
destination: NavDestination,
|
||||
arguments: Bundle?
|
||||
|
||||
+6
-3
@@ -68,7 +68,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatroomCompose(
|
||||
fun ChatroomHeaderCompose(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -305,13 +305,16 @@ private fun WatchNotificationChanges(
|
||||
@Composable
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
|
||||
var user by remember(baseUserHex) {
|
||||
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
|
||||
mutableStateOf(LocalCache.getUserIfExists(baseUserHex))
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
user = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
val newUser = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
if (user != newUser) {
|
||||
user = newUser
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -97,10 +97,15 @@ fun HashCheckFailedIcon(iconSize: Dp) {
|
||||
|
||||
@Composable
|
||||
fun LikedIcon(iconSize: Dp) {
|
||||
LikedIcon(modifier = remember(iconSize) { Modifier.size(iconSize) })
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikedIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember(iconSize) { Modifier.size(iconSize) },
|
||||
modifier = modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
@@ -295,6 +300,26 @@ fun PinIcon(modifier: Modifier, tint: Color) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsIcon(modifier: Modifier, tint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.lyrics_on),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsOffIcon(modifier: Modifier, tint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.lyrics_off),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearTextIcon() {
|
||||
Icon(
|
||||
@@ -318,7 +343,7 @@ fun VerticalDotsIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
@@ -336,7 +361,7 @@ fun NIP05CheckingIcon(modifier: Modifier) {
|
||||
@Composable
|
||||
fun NIP05VerifiedIcon(modifier: Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.nip_05_light),
|
||||
painter = painterResource(R.drawable.nip_05),
|
||||
contentDescription = stringResource(id = R.string.nip05_verified),
|
||||
modifier = modifier,
|
||||
tint = Color.Unspecified
|
||||
|
||||
@@ -234,7 +234,7 @@ fun RenderLikeGallery(
|
||||
)
|
||||
} else {
|
||||
when (val shortReaction = reactionType) {
|
||||
"+" -> LikedIcon(Size18dp)
|
||||
"+" -> LikedIcon(modifier.size(Size18dp))
|
||||
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
|
||||
else -> Text(text = shortReaction, modifier = modifier)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -32,8 +33,8 @@ import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
|
||||
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
|
||||
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05EmailColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.nip05
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -148,7 +149,7 @@ private fun DisplayNIP05(
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = remember(nip05) { AnnotatedString(user) },
|
||||
color = Nip05EmailColor,
|
||||
color = MaterialTheme.colors.nip05,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -159,7 +160,7 @@ private fun DisplayNIP05(
|
||||
ClickableText(
|
||||
text = remember(nip05) { AnnotatedString(domain) },
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = Nip05EmailColor),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
@@ -199,7 +200,7 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = remember { AnnotatedString(user + "@") },
|
||||
color = Nip05EmailColor,
|
||||
color = MaterialTheme.colors.nip05,
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -210,7 +211,7 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(domain),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = Nip05EmailColor),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.nip05),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
|
||||
@@ -170,7 +170,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
|
||||
@@ -1347,7 +1346,11 @@ fun RenderAppDefinition(
|
||||
)
|
||||
|
||||
if (zoomImageDialogOpen) {
|
||||
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
|
||||
ZoomableImageDialog(
|
||||
imageUrl = figureOutMimeType(it.banner!!),
|
||||
onDismiss = { zoomImageDialogOpen = false },
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Image(
|
||||
@@ -1398,7 +1401,11 @@ fun RenderAppDefinition(
|
||||
}
|
||||
|
||||
if (zoomImageDialogOpen) {
|
||||
ZoomableImageDialog(imageUrl = figureOutMimeType(it.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel)
|
||||
ZoomableImageDialog(
|
||||
imageUrl = figureOutMimeType(it.banner!!),
|
||||
onDismiss = { zoomImageDialogOpen = false },
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
@@ -1484,15 +1491,15 @@ private fun RenderHighlight(
|
||||
}
|
||||
|
||||
DisplayHighlight(
|
||||
quote,
|
||||
author,
|
||||
url,
|
||||
postHex,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
highlight = quote,
|
||||
authorHex = author,
|
||||
url = url,
|
||||
postAddress = postHex,
|
||||
makeItShort = makeItShort,
|
||||
canPreview = canPreview,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2222,7 +2229,7 @@ private fun RenderAudioTrack(
|
||||
) {
|
||||
val noteEvent = note.event as? AudioTrackEvent ?: return
|
||||
|
||||
AudioTrackHeader(noteEvent, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, note, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -2457,7 +2464,7 @@ private fun BoostedMark() {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
modifier = StdStartPadding
|
||||
modifier = HalfStartPadding
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2820,7 +2827,7 @@ fun DisplayFollowingHashtagsInPost(
|
||||
}
|
||||
|
||||
firstTag?.let {
|
||||
Column() {
|
||||
Column(verticalArrangement = Arrangement.Center) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
DisplayTagList(it, nav)
|
||||
}
|
||||
@@ -3132,11 +3139,25 @@ fun FileHeaderDisplay(note: Note, accountViewModel: AccountViewModel) {
|
||||
val description = event.content
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val uri = "nostr:" + note.toNEvent()
|
||||
val uri = note.toNostrUri()
|
||||
val newContent = if (isImage) {
|
||||
ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
|
||||
ZoomableUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri
|
||||
)
|
||||
} else {
|
||||
ZoomableUrlVideo(fullUrl, description, hash, uri)
|
||||
ZoomableUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName()
|
||||
)
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
@@ -3196,7 +3217,7 @@ private fun RenderNIP95(
|
||||
if (content == null) {
|
||||
LaunchedEffect(key1 = eventHeader.id, key2 = noteState, key3 = note?.event) {
|
||||
launch(Dispatchers.IO) {
|
||||
val uri = "nostr:" + header.toNEvent()
|
||||
val uri = header.toNostrUri()
|
||||
val localDir =
|
||||
note?.idHex?.let { File(File(appContext.externalCacheDir, "NIP95"), it) }
|
||||
val blurHash = eventHeader.blurhash()
|
||||
@@ -3221,7 +3242,8 @@ private fun RenderNIP95(
|
||||
description = description,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
uri = uri,
|
||||
authorName = header.author?.toBestDisplayName()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3242,7 +3264,7 @@ private fun RenderNIP95(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val media = remember { noteEvent.media() }
|
||||
val cover = remember { noteEvent.cover() }
|
||||
val subject = remember { noteEvent.subject() }
|
||||
@@ -3304,14 +3326,17 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, accountViewModel: AccountViewMo
|
||||
cover?.let { cover ->
|
||||
LoadThumbAndThenVideoView(
|
||||
videoUri = media,
|
||||
description = noteEvent.subject(),
|
||||
title = noteEvent.subject(),
|
||||
thumbUri = cover,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
nostrUriCallback = "nostr:${note.toNEvent()}",
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
?: VideoView(
|
||||
videoUri = media,
|
||||
description = noteEvent.subject(),
|
||||
title = noteEvent.subject(),
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
@@ -3436,8 +3461,11 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
description = subject,
|
||||
accountViewModel = accountViewModel
|
||||
title = subject,
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -22,6 +22,8 @@ fun timeAgo(mills: Long?, context: Context): String {
|
||||
.replace(" hr. ago", context.getString(R.string.h))
|
||||
.replace(" min. ago", context.getString(R.string.m))
|
||||
.replace(" days ago", context.getString(R.string.d))
|
||||
.replace(" hr ago", context.getString(R.string.h))
|
||||
.replace(" min ago", context.getString(R.string.m))
|
||||
.replace("Yesterday", "1" + context.getString(R.string.d))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,17 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel: ThemeViewModel, startingPage: String?) {
|
||||
fun AccountScreen(
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
themeViewModel: ThemeViewModel,
|
||||
navController: NavHostController
|
||||
) {
|
||||
val accountState by accountStateViewModel.accountContent.collectAsState()
|
||||
|
||||
Column() {
|
||||
@@ -27,7 +32,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
|
||||
factory = AccountViewModel.Factory(state.account)
|
||||
)
|
||||
|
||||
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
|
||||
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
|
||||
}
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
val accountViewModel: AccountViewModel = viewModel(
|
||||
@@ -35,7 +40,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, themeViewModel:
|
||||
factory = AccountViewModel.Factory(state.account)
|
||||
)
|
||||
|
||||
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, startingPage)
|
||||
MainScreen(accountViewModel, accountStateViewModel, themeViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomHeaderCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
@@ -113,7 +113,7 @@ private fun FeedLoaded(
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
ChatroomCompose(
|
||||
ChatroomHeaderCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
@Immutable
|
||||
class ThemeViewModel : ViewModel() {
|
||||
private val _theme = MutableLiveData(0)
|
||||
val theme: LiveData<Int> = _theme
|
||||
|
||||
@@ -377,7 +377,7 @@ fun NoteMaster(
|
||||
} else if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is AudioTrackEvent) {
|
||||
AudioTrackHeader(noteEvent, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, baseNote, accountViewModel, nav)
|
||||
} else if (noteEvent is CommunityPostApprovalEvent) {
|
||||
RenderPostApproval(
|
||||
baseNote,
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -124,6 +123,10 @@ import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
|
||||
@@ -351,15 +354,13 @@ fun EditFieldRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
onSendNewMessage: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = EditFieldModifier,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
MyTextField(
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = {
|
||||
@@ -368,7 +369,7 @@ fun EditFieldRow(
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
shape = RoundedCornerShape(25.dp),
|
||||
shape = EditFieldBorder,
|
||||
modifier = Modifier.weight(1f, true),
|
||||
placeholder = {
|
||||
Text(
|
||||
@@ -383,18 +384,14 @@ fun EditFieldRow(
|
||||
onSendNewMessage()
|
||||
},
|
||||
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(end = 10.dp)
|
||||
modifier = EditFieldTrailingIconModifier
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = 2.dp)
|
||||
modifier = EditFieldLeadingIconModifier
|
||||
) {
|
||||
val fileServer = if (isPrivate) {
|
||||
// TODO: Make private servers
|
||||
@@ -607,25 +604,47 @@ private fun ShowVideoStreaming(
|
||||
event = it,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
val streamingUrl by baseChannel.live.map {
|
||||
val streamingInfo by baseChannel.live.map {
|
||||
val activity = it.channel as? LiveActivitiesChannel
|
||||
activity?.info?.streaming()
|
||||
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming())
|
||||
activity?.info
|
||||
}.distinctUntilChanged().observeAsState(baseChannel.info)
|
||||
|
||||
streamingUrl?.let {
|
||||
CheckIfUrlIsOnline(it) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
val zoomableUrlVideo = remember(it) {
|
||||
ZoomableUrlVideo(url = it)
|
||||
streamingInfo?.let { event ->
|
||||
val url = remember(streamingInfo) {
|
||||
event.streaming()
|
||||
}
|
||||
val artworkUri = remember(streamingInfo) {
|
||||
event.image()
|
||||
}
|
||||
val title = remember(streamingInfo) {
|
||||
baseChannel.toBestDisplayName()
|
||||
}
|
||||
|
||||
val author = remember(streamingInfo) {
|
||||
baseChannel.creatorName()
|
||||
}
|
||||
|
||||
url?.let {
|
||||
CheckIfUrlIsOnline(url) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
val zoomableUrlVideo = remember(it) {
|
||||
ZoomableUrlVideo(
|
||||
url = url,
|
||||
description = title,
|
||||
artworkUri = artworkUri,
|
||||
authorName = author,
|
||||
uri = event.toNostrUri()
|
||||
)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
|
||||
@@ -49,26 +49,15 @@ fun ChatroomScreen(
|
||||
) {
|
||||
if (userId == null) return
|
||||
|
||||
var userRoom by remember(userId) { mutableStateOf<User?>(null) }
|
||||
|
||||
if (userRoom == null) {
|
||||
LaunchedEffect(userId) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
if (newUser != userRoom) {
|
||||
userRoom = newUser
|
||||
}
|
||||
}
|
||||
LoadUser(userId) {
|
||||
it?.let {
|
||||
PrepareChatroomViewModels(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
userRoom?.let {
|
||||
PrepareChatroomViewModels(
|
||||
baseUser = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -27,8 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
|
||||
@@ -61,10 +61,9 @@ fun MainScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
themeViewModel: ThemeViewModel,
|
||||
startingPage: String? = null
|
||||
navController: NavHostController
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
@@ -218,8 +217,7 @@ fun MainScreen(
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
navController = navController,
|
||||
accountViewModel = accountViewModel,
|
||||
themeViewModel = themeViewModel,
|
||||
nextPage = startingPage
|
||||
themeViewModel = themeViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,12 +50,16 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.HiddenNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteComposeReportState
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelay
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderReportState
|
||||
import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.WatchForReports
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedError
|
||||
@@ -68,6 +73,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun VideoScreen(
|
||||
@@ -213,7 +221,75 @@ fun SlidingCarousel(
|
||||
}
|
||||
) { index ->
|
||||
feed.value.getOrNull(index)?.let { note ->
|
||||
RenderVideoOrPictureNote(note, accountViewModel, nav)
|
||||
LoadedVideoCompose(note, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadedVideoCompose(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
RenderReportState(
|
||||
it,
|
||||
note,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderReportState(
|
||||
state: NoteComposeReportState,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
false,
|
||||
nav,
|
||||
onClick = { showReportedNote = true }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RenderVideoOrPictureNote(
|
||||
note,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +300,7 @@ private fun RenderVideoOrPictureNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column(remember { Modifier.fillMaxSize(1f) }) {
|
||||
Column(remember { Modifier.fillMaxSize(1f) }, verticalArrangement = Arrangement.Center) {
|
||||
Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) {
|
||||
val noteEvent = remember { note.event }
|
||||
if (noteEvent is FileHeaderEvent) {
|
||||
|
||||
@@ -12,10 +12,13 @@ val BitcoinOrange = Color(0xFFF7931A)
|
||||
val RoyalBlue = Color(0xFF4169E1)
|
||||
|
||||
val Following = Color(0xFF03DAC5)
|
||||
val Nip05EmailColor = Color(0xFFb198ec)
|
||||
val FollowsFollow = Color.Yellow
|
||||
val NIP05Verified = Color.Blue
|
||||
|
||||
val Nip05EmailColor = Color(0xFFb198ec)
|
||||
val Nip05EmailColorDark = Color(0xFF6e5490)
|
||||
val Nip05EmailColorLight = Color(0xFFa770f3)
|
||||
|
||||
val DarkerGreen = Color.Green.copy(alpha = 0.32f)
|
||||
|
||||
val WarningColor = Color(0xFFC62828)
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.theme
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -29,6 +28,7 @@ val TabRowHeight = Modifier.height(40.dp)
|
||||
val SmallBorder = RoundedCornerShape(7.dp)
|
||||
val QuoteBorder = RoundedCornerShape(15.dp)
|
||||
val ButtonBorder = RoundedCornerShape(20.dp)
|
||||
val EditFieldBorder = RoundedCornerShape(25.dp)
|
||||
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
@@ -69,6 +69,9 @@ val StdTopPadding = Modifier.padding(top = 10.dp)
|
||||
val HalfPadding = Modifier.padding(5.dp)
|
||||
val StdPadding = Modifier.padding(10.dp)
|
||||
|
||||
val HalfHorzPadding = Modifier.padding(horizontal = 5.dp)
|
||||
val HalfVertPadding = Modifier.padding(vertical = 5.dp)
|
||||
|
||||
val Size6Modifier = Modifier.size(6.dp)
|
||||
val Size10Modifier = Modifier.size(10.dp)
|
||||
val Size15Modifier = Modifier.size(15.dp)
|
||||
@@ -95,8 +98,8 @@ val DividerThickness = 0.25.dp
|
||||
|
||||
val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
|
||||
val ReactionRowHeightChat = Modifier.height(25.dp)
|
||||
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth()
|
||||
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth()
|
||||
val UserNameRowHeight = Modifier.fillMaxWidth()
|
||||
val UserNameMaxRowHeight = Modifier.fillMaxWidth()
|
||||
|
||||
val Height4dpModifier = Modifier.height(4.dp)
|
||||
|
||||
@@ -117,4 +120,15 @@ val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
|
||||
val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
|
||||
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
|
||||
|
||||
val EditFieldModifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
val EditFieldTrailingIconModifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = 5.dp, end = 10.dp)
|
||||
val EditFieldLeadingIconModifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = 2.dp)
|
||||
|
||||
@@ -151,7 +151,7 @@ val LightReplyBorderModifier = Modifier
|
||||
)
|
||||
|
||||
val DarkInnerPostBorderModifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
@@ -260,6 +260,9 @@ val Colors.veryImportantLink: Color
|
||||
val Colors.placeholderText: Color
|
||||
get() = if (isLight) LightPlaceholderText else DarkPlaceholderText
|
||||
|
||||
val Colors.nip05: Color
|
||||
get() = if (isLight) Nip05EmailColorLight else Nip05EmailColorDark
|
||||
|
||||
val Colors.placeholderTextColorFilter: ColorFilter
|
||||
get() = if (isLight) LightPlaceholderTextColorFilter else DarkPlaceholderTextColorFilter
|
||||
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
android:viewportHeight="30">
|
||||
<path
|
||||
android:pathData="m15.408,21.113 l-5.57,-5.026c-0.679,-0.679 -0.815,-1.766 -0.136,-2.445 0.679,-0.679 1.766,-0.815 2.445,-0.136L15,16.087 19.619,10.924c0.679,-0.679 1.766,-0.815 2.445,-0.136 0.679,0.679 0.815,1.766 0.136,2.445z"
|
||||
android:fillColor="#b198ec"/>
|
||||
android:fillColor="#6e5490"/>
|
||||
<path
|
||||
android:pathData="M15,0.056C6.849,0.056 0.056,6.713 0.056,15c0,8.287 6.657,14.944 14.944,14.944 0.951,0 1.766,-0.815 1.766,-1.766 0,-0.951 -0.815,-1.766 -1.766,-1.766 -6.249,0 -11.411,-5.162 -11.411,-11.411 0,-6.249 5.162,-11.411 11.411,-11.411 6.249,0 11.411,5.162 11.411,11.411 0,2.717 -1.494,5.298 -3.396,6.113 -1.223,0.543 -2.445,0.136 -3.668,-0.951l0,0l-2.309,2.581c2.174,2.038 4.755,2.717 7.336,1.63C27.634,23.015 29.944,19.211 29.944,15 29.808,6.713 23.151,0.056 15,0.056Z"
|
||||
android:fillColor="#b198ec"/>
|
||||
android:fillColor="#6e5490"/>
|
||||
</vector>
|
||||
@@ -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>
|
||||
@@ -4,20 +4,9 @@
|
||||
android:viewportWidth="30"
|
||||
android:viewportHeight="30">
|
||||
<path
|
||||
android:pathData="M15,15m-15,0a15,15 0,1 1,30 0a15,15 0,1 1,-30 0"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
android:pathData="m15.408,21.113 l-5.57,-5.026c-0.679,-0.679 -0.815,-1.766 -0.136,-2.445 0.679,-0.679 1.766,-0.815 2.445,-0.136L15,16.087 19.619,10.924c0.679,-0.679 1.766,-0.815 2.445,-0.136 0.679,0.679 0.815,1.766 0.136,2.445z"
|
||||
android:fillColor="#a770f3"/>
|
||||
<path
|
||||
android:pathData="M15.3,19.5l-4.1,-3.7c-0.5,-0.5 -0.6,-1.3 -0.1,-1.8s1.3,-0.6 1.8,-0.1l2.1,1.9l3.4,-3.8c0.5,-0.5 1.3,-0.6 1.8,-0.1s0.6,1.3 0.1,1.8L15.3,19.5z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M15.4,19.9L11.1,16c-0.6,-0.6 -0.7,-1.5 -0.1,-2.2c0.3,-0.3 0.7,-0.5 1.1,-0.5c0.4,0 0.8,0.1 1.1,0.4l2,1.8l3.3,-3.7c0.6,-0.6 1.5,-0.7 2.2,-0.1c0.3,0.3 0.5,0.6 0.5,1.1c0,0.4 -0.1,0.8 -0.4,1.1L15.4,19.9zM12.1,13.9L12.1,13.9c-0.3,0 -0.6,0.1 -0.8,0.3c-0.4,0.4 -0.3,1.1 0.1,1.5l3.9,3.5l5,-5.6c0.2,-0.2 0.3,-0.5 0.3,-0.7c0,-0.3 -0.1,-0.5 -0.3,-0.7c-0.4,-0.4 -1.1,-0.3 -1.5,0.1l-3.6,4l-2.4,-2.2C12.6,14 12.4,13.9 12.1,13.9z"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
<path
|
||||
android:pathData="M15,4C9,4 4,8.9 4,15s4.9,11 11,11c0.7,0 1.3,-0.6 1.3,-1.3c0,-0.7 -0.6,-1.3 -1.3,-1.3c-4.6,0 -8.4,-3.8 -8.4,-8.4s3.8,-8.4 8.4,-8.4s8.4,3.8 8.4,8.4c0,2 -1.1,3.9 -2.5,4.5c-0.9,0.4 -1.8,0.1 -2.7,-0.7l0,0l-1.7,1.9c1.6,1.5 3.5,2 5.4,1.2c2.4,-1 4.1,-3.8 4.1,-6.9C25.9,8.9 21,4 15,4z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M15,26.2c-6.2,0 -11.2,-5 -11.2,-11.2S8.8,3.8 15,3.8s11.2,5 11.2,11.2c0,3.1 -1.7,6.1 -4.2,7.1c-1.9,0.8 -4,0.3 -5.6,-1.3l-0.2,-0.2l2,-2.3l0.2,0.2l0,0c0.8,0.8 1.6,1 2.4,0.7c1.3,-0.6 2.3,-2.4 2.3,-4.2c0,-4.5 -3.7,-8.1 -8.1,-8.1c-4.5,-0.1 -8.2,3.6 -8.2,8.1s3.7,8.1 8.1,8.1c0.8,0 1.5,0.7 1.5,1.5C16.5,25.5 15.8,26.2 15,26.2zM15,4.3C9.1,4.3 4.3,9.1 4.3,15S9.1,25.7 15,25.7c0.6,0 1,-0.5 1,-1c0,-0.6 -0.5,-1 -1,-1c-4.8,0 -8.6,-3.9 -8.6,-8.6c0,-4.8 3.9,-8.6 8.6,-8.6s8.6,3.9 8.6,8.6c0,2.1 -1.1,4.1 -2.6,4.7c-0.9,0.4 -1.9,0.2 -2.8,-0.6l-1.4,1.4c1.5,1.3 3.3,1.7 4.9,1c2.3,-1 3.9,-3.7 3.9,-6.6C25.7,9.1 20.9,4.3 15,4.3z"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
android:pathData="M15,0.056C6.849,0.056 0.056,6.713 0.056,15c0,8.287 6.657,14.944 14.944,14.944 0.951,0 1.766,-0.815 1.766,-1.766 0,-0.951 -0.815,-1.766 -1.766,-1.766 -6.249,0 -11.411,-5.162 -11.411,-11.411 0,-6.249 5.162,-11.411 11.411,-11.411 6.249,0 11.411,5.162 11.411,11.411 0,2.717 -1.494,5.298 -3.396,6.113 -1.223,0.543 -2.445,0.136 -3.668,-0.951l0,0l-2.309,2.581c2.174,2.038 4.755,2.717 7.336,1.63C27.634,23.015 29.944,19.211 29.944,15 29.808,6.713 23.151,0.056 15,0.056Z"
|
||||
android:fillColor="#a770f3"/>
|
||||
</vector>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="30"
|
||||
android:viewportHeight="30">
|
||||
<path
|
||||
android:pathData="M15,15m-15,0a15,15 0,1 1,30 0a15,15 0,1 1,-30 0"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
<path
|
||||
android:pathData="M15.3,19.5l-4.1,-3.7c-0.5,-0.5 -0.6,-1.3 -0.1,-1.8s1.3,-0.6 1.8,-0.1l2.1,1.9l3.4,-3.8c0.5,-0.5 1.3,-0.6 1.8,-0.1s0.6,1.3 0.1,1.8L15.3,19.5z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M15.4,19.9L11.1,16c-0.6,-0.6 -0.7,-1.5 -0.1,-2.2c0.3,-0.3 0.7,-0.5 1.1,-0.5c0.4,0 0.8,0.1 1.1,0.4l2,1.8l3.3,-3.7c0.6,-0.6 1.5,-0.7 2.2,-0.1c0.3,0.3 0.5,0.6 0.5,1.1c0,0.4 -0.1,0.8 -0.4,1.1L15.4,19.9zM12.1,13.9L12.1,13.9c-0.3,0 -0.6,0.1 -0.8,0.3c-0.4,0.4 -0.3,1.1 0.1,1.5l3.9,3.5l5,-5.6c0.2,-0.2 0.3,-0.5 0.3,-0.7c0,-0.3 -0.1,-0.5 -0.3,-0.7c-0.4,-0.4 -1.1,-0.3 -1.5,0.1l-3.6,4l-2.4,-2.2C12.6,14 12.4,13.9 12.1,13.9z"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
<path
|
||||
android:pathData="M15,4C9,4 4,8.9 4,15s4.9,11 11,11c0.7,0 1.3,-0.6 1.3,-1.3c0,-0.7 -0.6,-1.3 -1.3,-1.3c-4.6,0 -8.4,-3.8 -8.4,-8.4s3.8,-8.4 8.4,-8.4s8.4,3.8 8.4,8.4c0,2 -1.1,3.9 -2.5,4.5c-0.9,0.4 -1.8,0.1 -2.7,-0.7l0,0l-1.7,1.9c1.6,1.5 3.5,2 5.4,1.2c2.4,-1 4.1,-3.8 4.1,-6.9C25.9,8.9 21,4 15,4z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
<path
|
||||
android:pathData="M15,26.2c-6.2,0 -11.2,-5 -11.2,-11.2S8.8,3.8 15,3.8s11.2,5 11.2,11.2c0,3.1 -1.7,6.1 -4.2,7.1c-1.9,0.8 -4,0.3 -5.6,-1.3l-0.2,-0.2l2,-2.3l0.2,0.2l0,0c0.8,0.8 1.6,1 2.4,0.7c1.3,-0.6 2.3,-2.4 2.3,-4.2c0,-4.5 -3.7,-8.1 -8.1,-8.1c-4.5,-0.1 -8.2,3.6 -8.2,8.1s3.7,8.1 8.1,8.1c0.8,0 1.5,0.7 1.5,1.5C16.5,25.5 15.8,26.2 15,26.2zM15,4.3C9.1,4.3 4.3,9.1 4.3,15S9.1,25.7 15,25.7c0.6,0 1,-0.5 1,-1c0,-0.6 -0.5,-1 -1,-1c-4.8,0 -8.6,-3.9 -8.6,-8.6c0,-4.8 3.9,-8.6 8.6,-8.6s8.6,3.9 8.6,8.6c0,2.1 -1.1,4.1 -2.6,4.7c-0.9,0.4 -1.9,0.2 -2.8,-0.6l-1.4,1.4c1.5,1.3 3.3,1.7 4.9,1c2.3,-1 3.9,-3.7 3.9,-6.6C25.7,9.1 20.9,4.3 15,4.3z"
|
||||
android:fillColor="#7F2EFF"/>
|
||||
</vector>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<string name="copy_note_id">Kopírovat ID poznámky</string>
|
||||
<string name="broadcast">Vysílání</string>
|
||||
<string name="request_deletion">Požadovat smazání</string>
|
||||
<string name="block_report">Blockovat / Nahlásit</string>
|
||||
<string name="block_hide_user"><![CDATA[Blokovat a skrýt uživatele]]></string>
|
||||
<string name="report_spam_scam">Nahlásit spam / podvod</string>
|
||||
<string name="report_impersonation">Nahlásit zneužívání identity</string>
|
||||
@@ -43,11 +44,11 @@
|
||||
<string name="new_amount_in_sats">Nová částka v sats</string>
|
||||
<string name="add">Přidat</string>
|
||||
<string name="replying_to">"odpovídá na "</string>
|
||||
<string name="and">"a "</string>
|
||||
<string name="and">" a "</string>
|
||||
<string name="in_channel">"v kanálu "</string>
|
||||
<string name="profile_banner">Banner profilu</string>
|
||||
<string name="following">"Sleduje"</string>
|
||||
<string name="followers">"Sledující"</string>
|
||||
<string name="following">" Sleduje"</string>
|
||||
<string name="followers">" Sledující"</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="security_filters">Bezpečnostní filtry</string>
|
||||
<string name="log_out">Odhlásit</string>
|
||||
@@ -164,6 +165,7 @@
|
||||
<string name="translations_show_in_lang_first">Zobrazit nejprve v %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Vždy překládat do %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nikdy nepřekládat z %1$s</string>
|
||||
<string name="nip_05">Nostr Adresa</string>
|
||||
<string name="never">nikdy</string>
|
||||
<string name="now">nyní</string>
|
||||
<string name="h">h</string>
|
||||
@@ -447,4 +449,38 @@
|
||||
<string name="followed_tags">Sledované značky</string>
|
||||
|
||||
<string name="relay_setup">Rele</string>
|
||||
|
||||
<string name="discover_live">Živě</string>
|
||||
<string name="discover_community">Komunita</string>
|
||||
<string name="discover_chat">Chaty</string>
|
||||
<string name="community_approved_posts">Schválené příspěvky</string>
|
||||
|
||||
<string name="groups_no_descriptor">Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a.</string>
|
||||
<string name="community_no_descriptor">Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Citlivý obsah</string>
|
||||
<string name="add_sensitive_content_description">Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah.</string>
|
||||
<string name="settings">Nastavení</string>
|
||||
<string name="always">Vždy</string>
|
||||
<string name="wifi_only">Pouze Wi-Fi</string>
|
||||
<string name="system">Systém</string>
|
||||
<string name="light">Světlý</string>
|
||||
<string name="dark">Tmavý</string>
|
||||
<string name="application_preferences">Předvolby aplikace</string>
|
||||
<string name="language">Jazyk</string>
|
||||
<string name="theme">Motiv</string>
|
||||
<string name="automatically_load_images_gifs">Automaticky načítat obrázky/gif</string>
|
||||
<string name="automatically_play_videos">Automaticky přehrávat videa</string>
|
||||
<string name="automatically_show_url_preview">Automaticky zobrazit náhled URL</string>
|
||||
<string name="load_image">Načíst obrázek</string>
|
||||
|
||||
<string name="spamming_users">Spamovací uživatelé</string>
|
||||
|
||||
<string name="muted_button">Ztlumené. Klikněte pro odztlumení</string>
|
||||
<string name="mute_button">Zvuk zapnutý. Klikněte pro ztlumení</string>
|
||||
<string name="search_button">Hledat lokální a vzdálené záznamy</string>
|
||||
|
||||
<string name="nip05_verified">Adresa Nostr byla ověřena</string>
|
||||
<string name="nip05_failed">Ověření adresy Nostr se nezdařilo</string>
|
||||
<string name="nip05_checking">Kontrola adresy Nostr</string>
|
||||
</resources>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="broadcast">Senden</string>
|
||||
<string name="request_deletion">Löschung beantragen</string>
|
||||
<string name="block_hide_user"><![CDATA[Benutzer blockieren und ausblenden]]></string>
|
||||
<string name="block_report">Blockieren / Melden</string>
|
||||
<string name="report_spam_scam">Spam / Betrug melden</string>
|
||||
<string name="report_impersonation">Vortäuschung melden</string>
|
||||
<string name="report_explicit_content">Expliziten Inhalt melden</string>
|
||||
@@ -168,6 +169,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="translations_show_in_lang_first">Zuerst in %1$s anzeigen</string>
|
||||
<string name="translations_always_translate_to_lang">Immer ins %1$s übersetzen</string>
|
||||
<string name="translations_never_translate_from_lang">Niemals aus dem %1$s übersetzen</string>
|
||||
<string name="nip_05">Nostr-Adresse</string>
|
||||
<string name="never">nie</string>
|
||||
<string name="now">jetzt</string>
|
||||
<string name="h">h</string>
|
||||
@@ -456,4 +458,38 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="followed_tags">Gefolgte Tags</string>
|
||||
|
||||
<string name="relay_setup">Relais</string>
|
||||
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Community</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
<string name="community_approved_posts">Genehmigte Beiträge</string>
|
||||
|
||||
<string name="groups_no_descriptor">Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
|
||||
<string name="community_no_descriptor">Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Sensibler Inhalt</string>
|
||||
<string name="add_sensitive_content_description">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird.</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="always">Immer</string>
|
||||
<string name="wifi_only">Nur WLAN</string>
|
||||
<string name="system">System</string>
|
||||
<string name="light">Hell</string>
|
||||
<string name="dark">Dunkel</string>
|
||||
<string name="application_preferences">Anwendungseinstellungen</string>
|
||||
<string name="language">Sprache</string>
|
||||
<string name="theme">Design</string>
|
||||
<string name="automatically_load_images_gifs">Bilder/GIFs automatisch laden</string>
|
||||
<string name="automatically_play_videos">Videos automatisch abspielen</string>
|
||||
<string name="automatically_show_url_preview">URL-Vorschau automatisch anzeigen</string>
|
||||
<string name="load_image">Bild laden</string>
|
||||
|
||||
<string name="spamming_users">Spammer</string>
|
||||
|
||||
<string name="muted_button">Ton aus. Klicken, um Ton einzuschalten</string>
|
||||
<string name="mute_button">Ton an. Klicken, um Ton auszuschalten</string>
|
||||
<string name="search_button">Lokale und entfernte Einträge durchsuchen</string>
|
||||
|
||||
<string name="nip05_verified">Nostr-Adresse verifiziert</string>
|
||||
<string name="nip05_failed">Nostr-Adresse konnte nicht verifiziert werden</string>
|
||||
<string name="nip05_checking">Nostr-Adresse wird überprüft</string>
|
||||
</resources>
|
||||
@@ -23,6 +23,7 @@
|
||||
<string name="broadcast">Sänd ut</string>
|
||||
<string name="request_deletion">Begär radering</string>
|
||||
<string name="block_hide_user"><![CDATA[Blockera och göm användare]]></string>
|
||||
<string name="block_report">Blockera / Rapportera</string>
|
||||
<string name="report_spam_scam">Rapportera Spam / Scam</string>
|
||||
<string name="report_impersonation">Rapportera identitetsstöld</string>
|
||||
<string name="report_explicit_content">Rapportera explicit innehåll</string>
|
||||
@@ -163,6 +164,7 @@
|
||||
<string name="translations_show_in_lang_first">Visa i %1$s first</string>
|
||||
<string name="translations_always_translate_to_lang">Översätt alltid till %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
|
||||
<string name="nip_05">Nostr-adress</string>
|
||||
<string name="never">aldrig</string>
|
||||
<string name="now">nu</string>
|
||||
<string name="h">h</string>
|
||||
@@ -444,6 +446,40 @@
|
||||
<string name="followed_tags">Följda taggar</string>
|
||||
|
||||
<string name="relay_setup">Reläer</string>
|
||||
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Gemenskap</string>
|
||||
<string name="discover_chat">Chattar</string>
|
||||
<string name="community_approved_posts">Godkända Inlägg</string>
|
||||
|
||||
<string name="groups_no_descriptor">Denna grupp har ingen beskrivning eller regler. Prata med ägaren för att lägga till en.</string>
|
||||
<string name="community_no_descriptor">Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Känsligt innehåll</string>
|
||||
<string name="add_sensitive_content_description">Lägger till varning för känsligt innehåll innan detta innehåll visas.</string>
|
||||
<string name="settings">Inställningar</string>
|
||||
<string name="always">Alltid</string>
|
||||
<string name="wifi_only">Endast Wi-Fi</string>
|
||||
<string name="system">System</string>
|
||||
<string name="light">Ljus</string>
|
||||
<string name="dark">Mörk</string>
|
||||
<string name="application_preferences">Applikationsinställningar</string>
|
||||
<string name="language">Språk</string>
|
||||
<string name="theme">Tema</string>
|
||||
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
|
||||
<string name="automatically_play_videos">Spela automatiskt videor</string>
|
||||
<string name="automatically_show_url_preview">Visa automatiskt förhandsgranskning av URL</string>
|
||||
<string name="load_image">Ladda bild</string>
|
||||
|
||||
<string name="spamming_users">Spammare</string>
|
||||
|
||||
<string name="muted_button">Ljud avstängt. Klicka för att ta bort ljudlöst</string>
|
||||
<string name="mute_button">Ljud på. Klicka för att stänga av ljudet</string>
|
||||
<string name="search_button">Sök lokala och externa poster</string>
|
||||
|
||||
<string name="nip05_verified">Nostr-adress verifierad</string>
|
||||
<string name="nip05_failed">Verifikation av Nostr-adress misslyckades</string>
|
||||
<string name="nip05_checking">Kontrollerar Nostr-adress</string>
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="ar"/>
|
||||
<locale android:name="cs"/>
|
||||
<locale android:name="de"/>
|
||||
<locale android:name="eo"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="fa"/>
|
||||
|
||||
Reference in New Issue
Block a user