Compare commits

..
49 Commits
Author SHA1 Message Date
Vitor Pamplona 78d2a712c5 v0.70.4 2023-07-21 17:29:51 -04:00
Vitor Pamplona 2664292993 Moves wake lock to activate only when the video is actually playing 2023-07-21 17:08:56 -04:00
Vitor Pamplona b9813c2c8f v0.70.3 2023-07-21 11:48:03 -04:00
Vitor Pamplona 0c00e6512f Modifier refactoring 2023-07-21 11:47:28 -04:00
Vitor Pamplona cf31bfe5ea Adds a space when rendering inline images 2023-07-21 11:47:08 -04:00
Vitor Pamplona 5cdceb5194 Moves the loading of an Accounts backup contacts to the IO Thread 2023-07-21 11:46:31 -04:00
Vitor Pamplona 2c82e6c447 Adjustments to Modifiers in the Chatroom screen 2023-07-21 11:21:07 -04:00
Vitor Pamplona f56f3b6105 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-07-21 11:16:55 -04:00
Vitor PamplonaandGitHub 9420ddc5c7 Merge pull request #517 from greenart7c3/main
fix KeepPlayingButton color in light theme
2023-07-21 11:16:41 -04:00
greenart7c3 ade1d83817 fix KeepPlayingButton color in light theme 2023-07-21 12:11:23 -03:00
Vitor Pamplona 9a517380a0 Refactors ChatroomHeader compose 2023-07-21 11:02:16 -04:00
Vitor Pamplona 6fbb59ddac v0.70.2 2023-07-21 10:44:43 -04:00
Vitor Pamplona fe4a4b6fad Stops Video playback when switching in and out of Tor 2023-07-21 10:44:02 -04:00
Vitor Pamplona 8dd5705f02 Merges VideoCache initialization with get methods 2023-07-21 10:08:04 -04:00
Vitor Pamplona 6723225885 Creates playback managers in a lazy mode, after the initialization of the video caching service. 2023-07-21 09:48:25 -04:00
Vitor Pamplona fb19454259 Moves the video caching service initialization foreground services. 2023-07-21 09:47:54 -04:00
Vitor PamplonaandGitHub c6f63a13d3 Merge pull request #511 from greenart7c3/main
add option to send notes to selected relays
2023-07-21 09:06:44 -04:00
Vitor Pamplona 77057fcac5 Apply hidden to boosted notes only. 2023-07-21 09:05:13 -04:00
Vitor PamplonaandGitHub 676d5e9b88 Merge pull request #516 from greenart7c3/boosted_blocked
fix boosted notes from blocked users appearing as blank
2023-07-21 08:58:05 -04:00
greenart7c3 d6dda0053a fix boosted notes from blocked users appearing as blank 2023-07-21 05:41:50 -03:00
greenart7c3andGitHub d33e62367e Merge branch 'main' into main 2023-07-21 05:24:12 -03:00
Vitor PamplonaandGitHub 47d7a53c1c Merge pull request #513 from davotoula/update_translations
Updated translations
2023-07-20 20:03:22 -04:00
Vitor PamplonaandGitHub 6fcf472638 Merge branch 'main' into update_translations 2023-07-20 20:03:17 -04:00
Vitor Pamplona e2f5a1a0e4 v0.70.1 2023-07-20 19:48:02 -04:00
Vitor Pamplona 8ea25e735d Fixes wakelock permission for the f-droid build 2023-07-20 19:46:55 -04:00
Vitor Pamplona a9d05070ed Remove restrictions of height in the first and second rows of the note. 2023-07-20 19:35:16 -04:00
Vitor Pamplona d78ec6b02a Centers the tag list vertically 2023-07-20 19:34:52 -04:00
Vitor Pamplona 8971b4e190 Samsung phones don't seem to have a . after hr and min 2023-07-20 19:34:33 -04:00
Vitor Pamplona dea0adaea4 Reducing the distance from boost to the play button 2023-07-20 19:34:12 -04:00
Vitor Pamplona 5de4fbcbe7 Fixing the size of the 3 dot menu in post 2023-07-20 19:33:54 -04:00
Vitor Pamplona 2330e086ea v0.70.0 2023-07-20 18:02:01 -04:00
Vitor Pamplona 11d51b3854 Wrong Immutable Class 2023-07-20 18:01:17 -04:00
Vitor Pamplona 008469adbc Background Playback
- Moves Video/Audio player to a foreground service.
- Migrates Feed, Stories and Live Stream screens to that service
- Blocks screen from going to sleep if a video is playing.
- Blocks WIFI from going to sleep if an online video is playing.
- Allows the app to pause while listening to media
- Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content
- Activates the use of popup with artwork that points to the screen with the video
- Creates a button to allow any video to play while browsing the app/phone
- Moves app to SingleTop mode.
- Keeps viewed position cached for up to 100 videos.
- Restructures the starting screen from App Nativation
2023-07-20 17:59:10 -04:00
David Kaspar bbe26c7a30 Updated se/de/cs translations
Added missing DE locale config
2023-07-18 20:29:19 +02:00
greenart7c3 c2b9bb413d add long press to show relay info, fix validation, show relay url without prefix 2023-07-17 17:08:14 -03:00
greenart7c3 5c1c4f8db2 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-07-17 15:44:33 -03:00
greenart7c3 b46bb7ecc5 add relay selection to nip 94 and 95 uploads 2023-07-17 15:43:49 -03:00
greenart7c3 2fed65db8d created RelaySelectionDialog file 2023-07-17 15:43:49 -03:00
greenart7c3 66cc4b5526 remove unused import 2023-07-17 15:43:49 -03:00
greenart7c3 91488f0350 add option to send notes to selected relays 2023-07-17 15:43:49 -03:00
Vitor Pamplona f2429e011e Upgrades the compressor API to the new version 2023-07-17 14:28:52 -04:00
Vitor Pamplona 1374f36b1f Updating libraries 2023-07-17 13:42:17 -04:00
Vitor Pamplona d185e65b3f Fixes Like icon aligned to the left in the Notifcations screen 2023-07-17 13:42:08 -04:00
Vitor Pamplona 64bf17b1a0 v0.69.3 2023-07-17 12:16:40 -04:00
Vitor Pamplona 93b50468fd Adjusts colors for the NÏP05 to be more subtle. 2023-07-17 12:14:21 -04:00
greenart7c3 d730eeb9a1 add relay selection to nip 94 and 95 uploads 2023-07-17 11:15:19 -03:00
greenart7c3 0d63441611 created RelaySelectionDialog file 2023-07-17 10:41:47 -03:00
greenart7c3 f0ecf382e0 remove unused import 2023-07-17 09:56:16 -03:00
greenart7c3 d0dee84031 add option to send notes to selected relays 2023-07-17 09:48:34 -03:00
56 changed files with 1887 additions and 404 deletions
+6 -5
View File
@@ -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(
+19
View File
@@ -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?
@@ -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
@@ -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>
+18
View File
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M810,40L810,261.02C800,257.68 790.83,255.01 782.5,253.01C774.17,251.01 766.64,250 759.92,250C729.39,250 703.44,260.66 682.07,281.99C660.7,303.33 650,329.29 650,359.88C650,390.47 660.66,416.48 681.99,437.89C703.33,459.3 729.29,470 759.88,470C790.47,470 816.48,459.32 837.89,437.93C859.3,416.54 870,390.56 870,360L870,100L960,100L960,40L810,40zM191.25,80L250.08,140L620,140L620,246.99C628.19,236.99 637.42,228 647.66,220C657.89,212 668.67,204.98 680,198.98L680,140C680,123.5 674.13,109.37 662.38,97.62C650.63,85.87 636.5,80 620,80L191.25,80zM80,195.08L80,880L240,720L594.84,720L535.98,660L140,660L140,256.25L80,195.08zM357.97,250L416.84,310L520,310L520,250L357.97,250zM240,370L240,430L310.43,430L251.56,370L240,370zM475.66,370L520,415.2L520,370L475.66,370zM620,473.01L620,517.15L680,578.32L680,521.02C668.67,515.02 657.89,508 647.66,500C637.42,492 628.19,483.01 620,473.01zM240,490L240,550L400,550L400,521.33L369.26,490L240,490z"/>
<path
android:pathData="M61.23,61.69 L873.53,889.89"
android:strokeWidth="160"
android:fillColor="#000000"/>
<path
android:pathData="M64.34,61.69 L876.65,889.89"
android:strokeWidth="60"
android:fillColor="#000000"
android:strokeColor="#000000"/>
</vector>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FF000000"
android:pathData="M140,660v-520,520ZM80,880v-740q0,-24.75 17.63,-42.38T140,80h480q24.75,0 42.38,17.63T680,140v59q-17,9 -32.36,21T620,247v-107L140,140v520h480v-187q12.29,15 27.64,27Q663,512 680,521v139q0,24.75 -17.63,42.38T620,720L240,720L80,880ZM240,550h160v-60L240,490v60ZM759.88,470Q714,470 682,437.88q-32,-32.12 -32,-78Q650,314 682.06,282q32.06,-32 77.86,-32 10.08,0 22.58,3t27.5,8v-221h150v60h-90v260q0,45.83 -32.12,77.92 -32.12,32.08 -78,32.08ZM240,430h280v-60L240,370v60ZM240,310h280v-60L240,250v60Z"/>
</vector>
+4 -15
View File
@@ -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>
+23
View File
@@ -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>
+39 -3
View File
@@ -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>
+36
View File
@@ -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>
+1
View File
@@ -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"/>