Background Playback
- Moves Video/Audio player to a foreground service. - Migrates Feed, Stories and Live Stream screens to that service - Blocks screen from going to sleep if a video is playing. - Blocks WIFI from going to sleep if an online video is playing. - Allows the app to pause while listening to media - Manages cache for up to 30 videos in parallel for each of the 3 categories: local, streaming, progressive content - Activates the use of popup with artwork that points to the screen with the video - Creates a button to allow any video to play while browsing the app/phone - Moves app to SingleTop mode. - Keeps viewed position cached for up to 100 videos. - Restructures the starting screen from App Nativation
This commit is contained in:
@@ -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.Manifest.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 {
|
||||
|
||||
@@ -192,7 +192,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?
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1347,7 +1347,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 +1402,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 +1492,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 +2230,7 @@ private fun RenderAudioTrack(
|
||||
) {
|
||||
val noteEvent = note.event as? AudioTrackEvent ?: return
|
||||
|
||||
AudioTrackHeader(noteEvent, accountViewModel, nav)
|
||||
AudioTrackHeader(noteEvent, note, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -3132,11 +3140,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 +3218,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 +3243,8 @@ private fun RenderNIP95(
|
||||
description = description,
|
||||
dim = dimensions,
|
||||
isVerified = true,
|
||||
uri = uri
|
||||
uri = uri,
|
||||
authorName = header.author?.toBestDisplayName()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3242,7 +3265,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 +3327,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 +3462,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.google.errorprone.annotations.Immutable
|
||||
|
||||
@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) {
|
||||
|
||||
@@ -117,4 +117,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)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M810,40L810,261.02C800,257.68 790.83,255.01 782.5,253.01C774.17,251.01 766.64,250 759.92,250C729.39,250 703.44,260.66 682.07,281.99C660.7,303.33 650,329.29 650,359.88C650,390.47 660.66,416.48 681.99,437.89C703.33,459.3 729.29,470 759.88,470C790.47,470 816.48,459.32 837.89,437.93C859.3,416.54 870,390.56 870,360L870,100L960,100L960,40L810,40zM191.25,80L250.08,140L620,140L620,246.99C628.19,236.99 637.42,228 647.66,220C657.89,212 668.67,204.98 680,198.98L680,140C680,123.5 674.13,109.37 662.38,97.62C650.63,85.87 636.5,80 620,80L191.25,80zM80,195.08L80,880L240,720L594.84,720L535.98,660L140,660L140,256.25L80,195.08zM357.97,250L416.84,310L520,310L520,250L357.97,250zM240,370L240,430L310.43,430L251.56,370L240,370zM475.66,370L520,415.2L520,370L475.66,370zM620,473.01L620,517.15L680,578.32L680,521.02C668.67,515.02 657.89,508 647.66,500C637.42,492 628.19,483.01 620,473.01zM240,490L240,550L400,550L400,521.33L369.26,490L240,490z"/>
|
||||
<path
|
||||
android:pathData="M61.23,61.69 L873.53,889.89"
|
||||
android:strokeWidth="160"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M64.34,61.69 L876.65,889.89"
|
||||
android:strokeWidth="60"
|
||||
android:fillColor="#000000"
|
||||
android:strokeColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M140,660v-520,520ZM80,880v-740q0,-24.75 17.63,-42.38T140,80h480q24.75,0 42.38,17.63T680,140v59q-17,9 -32.36,21T620,247v-107L140,140v520h480v-187q12.29,15 27.64,27Q663,512 680,521v139q0,24.75 -17.63,42.38T620,720L240,720L80,880ZM240,550h160v-60L240,490v60ZM759.88,470Q714,470 682,437.88q-32,-32.12 -32,-78Q650,314 682.06,282q32.06,-32 77.86,-32 10.08,0 22.58,3t27.5,8v-221h150v60h-90v260q0,45.83 -32.12,77.92 -32.12,32.08 -78,32.08ZM240,430h280v-60L240,370v60ZM240,310h280v-60L240,250v60Z"/>
|
||||
</vector>
|
||||
Reference in New Issue
Block a user