mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2024-09-30 00:40:49 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
d33e62367e
@ -13,14 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 256
|
||||
versionName "0.69.3"
|
||||
versionCode 258
|
||||
versionName "0.70.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@ -177,6 +177,7 @@ dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
|
||||
implementation 'androidx.media3:media3-ui:1.1.0'
|
||||
implementation 'androidx.media3:media3-session:1.1.0'
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
|
@ -4007,7 +4007,7 @@ class RichTextParserTest {
|
||||
"Bech(npub17m7f7q08k4x746s2v45eyvwppck32dcahw7uj2mu5txuswldgqkqw9zms7)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@ -4027,7 +4027,7 @@ class RichTextParserTest {
|
||||
Assert.assertTrue(state.imagesForPager.isEmpty())
|
||||
Assert.assertTrue(state.imageList.isEmpty())
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.firstOrNull()?.segmentText)
|
||||
Assert.assertEquals("Hi, how are you doing? ", state.paragraphs.firstOrNull()?.words?.firstOrNull()?.segmentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -4039,7 +4039,7 @@ class RichTextParserTest {
|
||||
Assert.assertTrue(state.customEmoji.isEmpty())
|
||||
Assert.assertEquals(
|
||||
"\nHi, \nhow\n\n\n are you doing? \n",
|
||||
state.paragraphs.joinToString("\n") { it.joinToString(" ") { it.segmentText } }
|
||||
state.paragraphs.joinToString("\n") { it.words.joinToString(" ") { it.segmentText } }
|
||||
)
|
||||
}
|
||||
|
||||
@ -4102,7 +4102,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
|
||||
)
|
||||
|
||||
state.paragraphs.flatten().forEachIndexed { index, seg ->
|
||||
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
|
||||
Assert.assertEquals(
|
||||
expectedResult[index],
|
||||
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
|
||||
@ -4112,7 +4112,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
|
||||
|
||||
private fun printStateForDebug(state: RichTextViewerState) {
|
||||
state.paragraphs.forEachIndexed { index, paragraph ->
|
||||
paragraph.forEach { seg ->
|
||||
paragraph.words.forEach { seg ->
|
||||
println(
|
||||
"\"${
|
||||
seg.javaClass.simpleName.replace(
|
||||
|
@ -27,6 +27,13 @@
|
||||
<!-- To know receive notifications when the app connects/disconnects from the web -->
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- Audio/Video Playback -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Keeps screen on while playing videos -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Old permission to access media -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@ -51,6 +58,7 @@
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
@ -91,6 +99,17 @@
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService"/>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
@ -0,0 +1,121 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.LruCache
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null) {
|
||||
private val cachedPositions = LruCache<String, Long>(100)
|
||||
|
||||
// protects from LruCache killing playing sessions
|
||||
private val playingMap = mutableMapOf<String, MediaSession>()
|
||||
|
||||
private val cache =
|
||||
object : LruCache<String, MediaSession>(10) { // up to 10 videos in the screen at the same time
|
||||
override fun entryRemoved(
|
||||
evicted: Boolean,
|
||||
key: String?,
|
||||
oldValue: MediaSession?,
|
||||
newValue: MediaSession?
|
||||
) {
|
||||
super.entryRemoved(evicted, key, oldValue, newValue)
|
||||
|
||||
if (!playingMap.contains(key)) {
|
||||
oldValue?.let {
|
||||
it.player.release()
|
||||
it.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCallbackIntent(callbackUri: String, applicationContext: Context): PendingIntent {
|
||||
return PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
Intent(Intent.ACTION_VIEW, callbackUri.toUri(), applicationContext, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun getMediaSession(id: String, uri: String, callbackUri: String?, context: Context, applicationContext: Context): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
if (existingSession != null) return existingSession
|
||||
|
||||
val player = ExoPlayer.Builder(context).run {
|
||||
dataSourceFactory?.let { setMediaSourceFactory(it) }
|
||||
build()
|
||||
}
|
||||
|
||||
player.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
}
|
||||
|
||||
val mediaSession = MediaSession.Builder(context, player).run {
|
||||
callbackUri?.let {
|
||||
setSessionActivity(getCallbackIntent(it, applicationContext))
|
||||
}
|
||||
setId(id)
|
||||
build()
|
||||
}
|
||||
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.put(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (player.currentPosition > 5 * 60) { // 5 seconds
|
||||
cachedPositions.put(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
cache.put(id, mediaSession)
|
||||
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
fun releaseAppPlayers() {
|
||||
cache.evictAll()
|
||||
}
|
||||
|
||||
fun playingContent(): Collection<MediaSession> {
|
||||
return playingMap.values
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
|
||||
object PlaybackClientController {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun prepareController(
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit
|
||||
) {
|
||||
// creating a bundle object
|
||||
// creating a bundle object
|
||||
val bundle = Bundle()
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
|
||||
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
|
||||
val controllerFuture = MediaController
|
||||
.Builder(context, sessionTokenLocal)
|
||||
.setConnectionHints(bundle)
|
||||
.buildAsync()
|
||||
|
||||
controllerFuture.addListener(
|
||||
{
|
||||
val controller = controllerFuture.get()
|
||||
onReady(controller)
|
||||
},
|
||||
MoreExecutors.directExecutor()
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private val managerHls = MultiPlayerPlaybackManager(HlsMediaSource.Factory(OkHttpDataSource.Factory(HttpClient.getHttpClient())))
|
||||
private val managerProgressive = MultiPlayerPlaybackManager(ProgressiveMediaSource.Factory(VideoCache.get()))
|
||||
private val managerLocal = MultiPlayerPlaybackManager()
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
setMediaNotificationProvider(
|
||||
DefaultMediaNotificationProvider.Builder(applicationContext)
|
||||
// .setNotificationIdProvider { session -> session.id.hashCode() }
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
managerHls.releaseAppPlayers()
|
||||
managerLocal.releaseAppPlayers()
|
||||
managerProgressive.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager {
|
||||
return if (fileName.startsWith("file")) {
|
||||
managerLocal
|
||||
} else if (fileName.endsWith("m3u8")) {
|
||||
managerHls
|
||||
} else {
|
||||
managerProgressive
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
// Updates any new player ready
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerHls.playingContent().forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal.playingContent().forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive.playingContent().forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerHls.playingContent().forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerLocal.playingContent().forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
managerProgressive.playingContent().forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a MediaSession to link with the MediaController that is making
|
||||
// this request.
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
|
||||
val manager = getAppropriateMediaSessionManager(uri)
|
||||
|
||||
return manager.getMediaSession(id, uri, callbackUri, context = this, applicationContext = applicationContext)
|
||||
}
|
||||
}
|
@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.HttpClient
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
@Synchronized
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
object VideoViewedPositionCache {
|
||||
val cachedPosition = LruCache<String, Long>(10)
|
||||
|
||||
fun add(uri: String, position: Long) {
|
||||
cachedPosition.put(uri, position)
|
||||
}
|
||||
|
||||
fun get(uri: String): Long? {
|
||||
return cachedPosition.get(uri)
|
||||
}
|
||||
}
|
@ -95,6 +95,10 @@ abstract class Channel(val idHex: String) {
|
||||
return null
|
||||
}
|
||||
|
||||
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? {
|
||||
|
@ -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 {
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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 {
|
||||
|
@ -238,7 +238,10 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it.toString(), accountViewModel = accountViewModel)
|
||||
VideoView(
|
||||
videoUri = it.toString(),
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
@ -22,12 +28,13 @@ import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@ -39,38 +46,41 @@ import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MutedIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
|
||||
public var DefaultMutedSetting = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
title: String? = null,
|
||||
thumbUri: String,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
@ -97,95 +107,73 @@ fun LoadThumbAndThenVideoView(
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog)
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog
|
||||
)
|
||||
} else {
|
||||
VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog)
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
title = title,
|
||||
thumb = null,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo)
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView1(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false
|
||||
) {
|
||||
var exoPlayerData by remember { mutableStateOf<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 +189,281 @@ fun VideoView1(
|
||||
)
|
||||
}
|
||||
|
||||
exoPlayerData.exoPlayer.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = if (defaultToStart) 0f else 1f
|
||||
if (videoUri.startsWith("file")) {
|
||||
setMediaItem(media)
|
||||
} else if (videoUri.endsWith("m3u8")) {
|
||||
// Should not use cache.
|
||||
val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
setMediaSource(
|
||||
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayerData.exoPlayer.pause()
|
||||
}
|
||||
else -> {}
|
||||
VideoPlayerMutex(videoUri) { activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
val lifecycle = lifecycleOwner.value.lifecycle
|
||||
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycle.removeObserver(observer)
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
nostrUriCallback = nostrUriCallback
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(controller, thumb, keepPlaying, automaticallyStartPlayback, activeOnScreen, onDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
mediaItem: MediaItem,
|
||||
videoUri: String,
|
||||
defaultToStart: Boolean = false,
|
||||
nostrUriCallback: String? = null,
|
||||
inner: @Composable (controller: MediaController, keepPlaying: MutableState<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 +471,32 @@ data class VideoThumb(
|
||||
)
|
||||
|
||||
@Composable
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun RenderVideoPlayer(
|
||||
playerData: VideoPlayer,
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
keepPlaying: MutableState<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 +508,102 @@ private fun RenderVideoPlayer(
|
||||
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
playerData.exoPlayer.pause()
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener {
|
||||
controllerVisible.value = it == View.VISIBLE
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton() { mute: Boolean ->
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
|
||||
MuteButton(controllerVisible, startingMuteState) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DefaultMutedSetting.value = mute
|
||||
|
||||
playerData.exoPlayer.volume = if (mute) 0f else 1f
|
||||
// if the user unmutes a video and it's not the current playing, switches to that one.
|
||||
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
KeepPlayingButton(keepPlaying, controllerVisible, Modifier.align(Alignment.TopEnd)) { newKeepPlaying: Boolean ->
|
||||
// If something else is playing and the user marks this video to keep playing, stops the other one.
|
||||
if (newKeepPlaying) {
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
keepPlayingMutex?.stop()
|
||||
}
|
||||
keepPlayingMutex = controller
|
||||
} else {
|
||||
if (keepPlayingMutex == controller) {
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
}
|
||||
|
||||
keepPlaying.value = newKeepPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ControlWhenPlayerIsActive(
|
||||
controller: Player,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: MutableState<Boolean>,
|
||||
activeOnScreen: MutableState<Boolean>
|
||||
) {
|
||||
// active means being fully visible
|
||||
if (activeOnScreen.value) {
|
||||
// should auto start video from settings?
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
if (controller.isPlaying) {
|
||||
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
||||
controller.pause()
|
||||
}
|
||||
} else if (!controller.isPlaying) {
|
||||
// if it is visible, was supposed to start automatically, but it's not
|
||||
|
||||
// If something else is playing, play on mute.
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
controller.volume = 0f
|
||||
}
|
||||
controller.play()
|
||||
}
|
||||
} else {
|
||||
// Pauses the video when it becomes invisible.
|
||||
// Destroys the video later when it Disposes the element
|
||||
// meanwhile if the user comes back, the position in the track is saved.
|
||||
if (!keepPlaying.value) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
|
||||
// Keeps the screen on while playing and viewing videos.
|
||||
DisposableEffect(key1 = controller) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
// being played in the mutex is not visible.
|
||||
view.keepScreenOn = isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
controller.addListener(listener)
|
||||
onDispose {
|
||||
controller.removeListener(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -333,12 +624,15 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
|
||||
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
if (!isAttached) return false
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = android.graphics.Rect()
|
||||
val globalRootRect = Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return false
|
||||
}
|
||||
val bounds = boundsInWindow()
|
||||
|
||||
if (bounds.isEmpty) return false
|
||||
|
||||
// Make sure we are completely in bounds.
|
||||
return bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
@ -346,30 +640,127 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
}
|
||||
|
||||
fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Float?) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
onVisiblePosition(coordinates.getDistanceToVertCenterIfVisible(view))
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
if (!isAttached) return null
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return null
|
||||
}
|
||||
|
||||
val bounds = boundsInWindow()
|
||||
|
||||
if (bounds.isEmpty) return null
|
||||
|
||||
// Make sure we are completely in bounds.
|
||||
if (bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
bounds.right <= globalRootRect.right &&
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
) {
|
||||
return abs(((bounds.top + bounds.bottom) / 2) - ((globalRootRect.top + globalRootRect.bottom) / 2))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(toggle: (Boolean) -> Unit) {
|
||||
Box(modifier = VolumeBottomIconSize) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<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, Color.White)
|
||||
} else {
|
||||
LyricsOffIcon(Size22Modifier, Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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?
|
||||
|
@ -29,7 +29,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@ -300,6 +300,26 @@ fun PinIcon(modifier: Modifier, tint: Color) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsIcon(modifier: Modifier, tint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.lyrics_on),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LyricsOffIcon(modifier: Modifier, tint: Color) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.lyrics_off),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
tint = tint
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClearTextIcon() {
|
||||
Icon(
|
||||
@ -323,7 +343,7 @@ fun VerticalDotsIcon() {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
modifier = Size18Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -607,25 +607,47 @@ private fun ShowVideoStreaming(
|
||||
event = it,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
val streamingUrl by baseChannel.live.map {
|
||||
val streamingInfo by baseChannel.live.map {
|
||||
val activity = it.channel as? LiveActivitiesChannel
|
||||
activity?.info?.streaming()
|
||||
}.distinctUntilChanged().observeAsState(baseChannel.info?.streaming())
|
||||
activity?.info
|
||||
}.distinctUntilChanged().observeAsState(baseChannel.info)
|
||||
|
||||
streamingUrl?.let {
|
||||
CheckIfUrlIsOnline(it) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
val zoomableUrlVideo = remember(it) {
|
||||
ZoomableUrlVideo(url = it)
|
||||
streamingInfo?.let { event ->
|
||||
val url = remember(streamingInfo) {
|
||||
event.streaming()
|
||||
}
|
||||
val artworkUri = remember(streamingInfo) {
|
||||
event.image()
|
||||
}
|
||||
val title = remember(streamingInfo) {
|
||||
baseChannel.toBestDisplayName()
|
||||
}
|
||||
|
||||
val author = remember(streamingInfo) {
|
||||
baseChannel.creatorName()
|
||||
}
|
||||
|
||||
url?.let {
|
||||
CheckIfUrlIsOnline(url) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
val zoomableUrlVideo = remember(it) {
|
||||
ZoomableUrlVideo(
|
||||
url = url,
|
||||
description = title,
|
||||
artworkUri = artworkUri,
|
||||
authorName = author,
|
||||
uri = event.toNostrUri()
|
||||
)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
ZoomableContentView(
|
||||
content = zoomableUrlVideo,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.theme
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@ -95,8 +94,8 @@ val DividerThickness = 0.25.dp
|
||||
|
||||
val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
|
||||
val ReactionRowHeightChat = Modifier.height(25.dp)
|
||||
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth()
|
||||
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth()
|
||||
val UserNameRowHeight = Modifier.fillMaxWidth()
|
||||
val UserNameMaxRowHeight = Modifier.fillMaxWidth()
|
||||
|
||||
val Height4dpModifier = Modifier.height(4.dp)
|
||||
|
||||
@ -117,4 +116,5 @@ val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
|
||||
val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
|
||||
val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp)
|
||||
val NIP05IconSize = Modifier.size(14.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp)
|
||||
|
18
app/src/main/res/drawable/lyrics_off.xml
Normal file
18
app/src/main/res/drawable/lyrics_off.xml
Normal 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
app/src/main/res/drawable/lyrics_on.xml
Normal file
9
app/src/main/res/drawable/lyrics_on.xml
Normal 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>
|
@ -23,6 +23,7 @@
|
||||
<string name="copy_note_id">Kopírovat ID poznámky</string>
|
||||
<string name="broadcast">Vysílání</string>
|
||||
<string name="request_deletion">Požadovat smazání</string>
|
||||
<string name="block_report">Blockovat / Nahlásit</string>
|
||||
<string name="block_hide_user"><![CDATA[Blokovat a skrýt uživatele]]></string>
|
||||
<string name="report_spam_scam">Nahlásit spam / podvod</string>
|
||||
<string name="report_impersonation">Nahlásit zneužívání identity</string>
|
||||
@ -43,11 +44,11 @@
|
||||
<string name="new_amount_in_sats">Nová částka v sats</string>
|
||||
<string name="add">Přidat</string>
|
||||
<string name="replying_to">"odpovídá na "</string>
|
||||
<string name="and">"a "</string>
|
||||
<string name="and">" a "</string>
|
||||
<string name="in_channel">"v kanálu "</string>
|
||||
<string name="profile_banner">Banner profilu</string>
|
||||
<string name="following">"Sleduje"</string>
|
||||
<string name="followers">"Sledující"</string>
|
||||
<string name="following">" Sleduje"</string>
|
||||
<string name="followers">" Sledující"</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="security_filters">Bezpečnostní filtry</string>
|
||||
<string name="log_out">Odhlásit</string>
|
||||
@ -164,6 +165,7 @@
|
||||
<string name="translations_show_in_lang_first">Zobrazit nejprve v %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">Vždy překládat do %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nikdy nepřekládat z %1$s</string>
|
||||
<string name="nip_05">Nostr Adresa</string>
|
||||
<string name="never">nikdy</string>
|
||||
<string name="now">nyní</string>
|
||||
<string name="h">h</string>
|
||||
@ -447,4 +449,38 @@
|
||||
<string name="followed_tags">Sledované značky</string>
|
||||
|
||||
<string name="relay_setup">Rele</string>
|
||||
|
||||
<string name="discover_live">Živě</string>
|
||||
<string name="discover_community">Komunita</string>
|
||||
<string name="discover_chat">Chaty</string>
|
||||
<string name="community_approved_posts">Schválené příspěvky</string>
|
||||
|
||||
<string name="groups_no_descriptor">Tato skupina nemá popis ani pravidla. Promluvte si s majitelem, aby je přidal/a.</string>
|
||||
<string name="community_no_descriptor">Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Citlivý obsah</string>
|
||||
<string name="add_sensitive_content_description">Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah.</string>
|
||||
<string name="settings">Nastavení</string>
|
||||
<string name="always">Vždy</string>
|
||||
<string name="wifi_only">Pouze Wi-Fi</string>
|
||||
<string name="system">Systém</string>
|
||||
<string name="light">Světlý</string>
|
||||
<string name="dark">Tmavý</string>
|
||||
<string name="application_preferences">Předvolby aplikace</string>
|
||||
<string name="language">Jazyk</string>
|
||||
<string name="theme">Motiv</string>
|
||||
<string name="automatically_load_images_gifs">Automaticky načítat obrázky/gif</string>
|
||||
<string name="automatically_play_videos">Automaticky přehrávat videa</string>
|
||||
<string name="automatically_show_url_preview">Automaticky zobrazit náhled URL</string>
|
||||
<string name="load_image">Načíst obrázek</string>
|
||||
|
||||
<string name="spamming_users">Spamovací uživatelé</string>
|
||||
|
||||
<string name="muted_button">Ztlumené. Klikněte pro odztlumení</string>
|
||||
<string name="mute_button">Zvuk zapnutý. Klikněte pro ztlumení</string>
|
||||
<string name="search_button">Hledat lokální a vzdálené záznamy</string>
|
||||
|
||||
<string name="nip05_verified">Adresa Nostr byla ověřena</string>
|
||||
<string name="nip05_failed">Ověření adresy Nostr se nezdařilo</string>
|
||||
<string name="nip05_checking">Kontrola adresy Nostr</string>
|
||||
</resources>
|
||||
|
@ -24,6 +24,7 @@
|
||||
<string name="broadcast">Senden</string>
|
||||
<string name="request_deletion">Löschung beantragen</string>
|
||||
<string name="block_hide_user"><![CDATA[Benutzer blockieren und ausblenden]]></string>
|
||||
<string name="block_report">Blockieren / Melden</string>
|
||||
<string name="report_spam_scam">Spam / Betrug melden</string>
|
||||
<string name="report_impersonation">Vortäuschung melden</string>
|
||||
<string name="report_explicit_content">Expliziten Inhalt melden</string>
|
||||
@ -168,6 +169,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="translations_show_in_lang_first">Zuerst in %1$s anzeigen</string>
|
||||
<string name="translations_always_translate_to_lang">Immer ins %1$s übersetzen</string>
|
||||
<string name="translations_never_translate_from_lang">Niemals aus dem %1$s übersetzen</string>
|
||||
<string name="nip_05">Nostr-Adresse</string>
|
||||
<string name="never">nie</string>
|
||||
<string name="now">jetzt</string>
|
||||
<string name="h">h</string>
|
||||
@ -456,4 +458,38 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="followed_tags">Gefolgte Tags</string>
|
||||
|
||||
<string name="relay_setup">Relais</string>
|
||||
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Community</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
<string name="community_approved_posts">Genehmigte Beiträge</string>
|
||||
|
||||
<string name="groups_no_descriptor">Diese Gruppe hat keine Beschreibung oder Regeln. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
|
||||
<string name="community_no_descriptor">Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Sensibler Inhalt</string>
|
||||
<string name="add_sensitive_content_description">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird.</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="always">Immer</string>
|
||||
<string name="wifi_only">Nur WLAN</string>
|
||||
<string name="system">System</string>
|
||||
<string name="light">Hell</string>
|
||||
<string name="dark">Dunkel</string>
|
||||
<string name="application_preferences">Anwendungseinstellungen</string>
|
||||
<string name="language">Sprache</string>
|
||||
<string name="theme">Design</string>
|
||||
<string name="automatically_load_images_gifs">Bilder/GIFs automatisch laden</string>
|
||||
<string name="automatically_play_videos">Videos automatisch abspielen</string>
|
||||
<string name="automatically_show_url_preview">URL-Vorschau automatisch anzeigen</string>
|
||||
<string name="load_image">Bild laden</string>
|
||||
|
||||
<string name="spamming_users">Spammer</string>
|
||||
|
||||
<string name="muted_button">Ton aus. Klicken, um Ton einzuschalten</string>
|
||||
<string name="mute_button">Ton an. Klicken, um Ton auszuschalten</string>
|
||||
<string name="search_button">Lokale und entfernte Einträge durchsuchen</string>
|
||||
|
||||
<string name="nip05_verified">Nostr-Adresse verifiziert</string>
|
||||
<string name="nip05_failed">Nostr-Adresse konnte nicht verifiziert werden</string>
|
||||
<string name="nip05_checking">Nostr-Adresse wird überprüft</string>
|
||||
</resources>
|
@ -23,6 +23,7 @@
|
||||
<string name="broadcast">Sänd ut</string>
|
||||
<string name="request_deletion">Begär radering</string>
|
||||
<string name="block_hide_user"><![CDATA[Blockera och göm användare]]></string>
|
||||
<string name="block_report">Blockera / Rapportera</string>
|
||||
<string name="report_spam_scam">Rapportera Spam / Scam</string>
|
||||
<string name="report_impersonation">Rapportera identitetsstöld</string>
|
||||
<string name="report_explicit_content">Rapportera explicit innehåll</string>
|
||||
@ -163,6 +164,7 @@
|
||||
<string name="translations_show_in_lang_first">Visa i %1$s first</string>
|
||||
<string name="translations_always_translate_to_lang">Översätt alltid till %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
|
||||
<string name="nip_05">Nostr-adress</string>
|
||||
<string name="never">aldrig</string>
|
||||
<string name="now">nu</string>
|
||||
<string name="h">h</string>
|
||||
@ -444,6 +446,40 @@
|
||||
<string name="followed_tags">Följda taggar</string>
|
||||
|
||||
<string name="relay_setup">Reläer</string>
|
||||
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Gemenskap</string>
|
||||
<string name="discover_chat">Chattar</string>
|
||||
<string name="community_approved_posts">Godkända Inlägg</string>
|
||||
|
||||
<string name="groups_no_descriptor">Denna grupp har ingen beskrivning eller regler. Prata med ägaren för att lägga till en.</string>
|
||||
<string name="community_no_descriptor">Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en.</string>
|
||||
|
||||
<string name="add_sensitive_content_label">Känsligt innehåll</string>
|
||||
<string name="add_sensitive_content_description">Lägger till varning för känsligt innehåll innan detta innehåll visas.</string>
|
||||
<string name="settings">Inställningar</string>
|
||||
<string name="always">Alltid</string>
|
||||
<string name="wifi_only">Endast Wi-Fi</string>
|
||||
<string name="system">System</string>
|
||||
<string name="light">Ljus</string>
|
||||
<string name="dark">Mörk</string>
|
||||
<string name="application_preferences">Applikationsinställningar</string>
|
||||
<string name="language">Språk</string>
|
||||
<string name="theme">Tema</string>
|
||||
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
|
||||
<string name="automatically_play_videos">Spela automatiskt videor</string>
|
||||
<string name="automatically_show_url_preview">Visa automatiskt förhandsgranskning av URL</string>
|
||||
<string name="load_image">Ladda bild</string>
|
||||
|
||||
<string name="spamming_users">Spammare</string>
|
||||
|
||||
<string name="muted_button">Ljud avstängt. Klicka för att ta bort ljudlöst</string>
|
||||
<string name="mute_button">Ljud på. Klicka för att stänga av ljudet</string>
|
||||
<string name="search_button">Sök lokala och externa poster</string>
|
||||
|
||||
<string name="nip05_verified">Nostr-adress verifierad</string>
|
||||
<string name="nip05_failed">Verifikation av Nostr-adress misslyckades</string>
|
||||
<string name="nip05_checking">Kontrollerar Nostr-adress</string>
|
||||
</resources>
|
||||
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="ar"/>
|
||||
<locale android:name="cs"/>
|
||||
<locale android:name="de"/>
|
||||
<locale android:name="eo"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="fa"/>
|
||||
|
Loading…
Reference in New Issue
Block a user