diff --git a/.idea/icon.svg b/.idea/icon.svg
new file mode 100644
index 000000000..87f110118
--- /dev/null
+++ b/.idea/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/amethyst/build.gradle b/amethyst/build.gradle
index 7ba5f6d39..6977e7632 100644
--- a/amethyst/build.gradle
+++ b/amethyst/build.gradle
@@ -104,6 +104,9 @@ android {
}
create("benchmark") {
initWith(getByName("release"))
+ applicationIdSuffix '.benchmark'
+ versionNameSuffix '-BENCHMARK'
+ resValue "string", "app_name", "@string/app_name_benchmark"
profileable true
signingConfig signingConfigs.debug
}
diff --git a/amethyst/google-services.json b/amethyst/google-services.json
index 4a9fa85f5..31ca5e8ec 100644
--- a/amethyst/google-services.json
+++ b/amethyst/google-services.json
@@ -62,6 +62,35 @@
]
}
}
+ },
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
+ "android_client_info": {
+ "package_name": "com.vitorpamplona.amethyst.benchmark"
+ }
+ },
+ "oauth_client": [
+ {
+ "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ],
+ "api_key": [
+ {
+ "current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": [
+ {
+ "client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
+ "client_type": 3
+ }
+ ]
+ }
+ }
}
],
"configuration_version": "1"
diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro
index 7790abd2b..c84569d50 100644
--- a/amethyst/proguard-rules.pro
+++ b/amethyst/proguard-rules.pro
@@ -38,6 +38,8 @@
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
+-keep class info.guardianproject.** { *; }
+
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
diff --git a/amethyst/src/androidTest/res/values/strings.xml b/amethyst/src/androidTest/res/values/strings.xml
index 562719f05..024194479 100644
--- a/amethyst/src/androidTest/res/values/strings.xml
+++ b/amethyst/src/androidTest/res/values/strings.xml
@@ -1,4 +1,5 @@
Amethyst
- Amethyst Debug
+ Amy Debug
+ Amy Benchmark
\ No newline at end of file
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index c606f11be..952c8b424 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -122,7 +122,7 @@
tools:replace="screenOrientation" />
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
index b83a2c3dc..3d4d8b541 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst
import android.app.Application
+import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
+import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Looper
@@ -30,6 +32,7 @@ import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import android.util.Log
+import androidx.core.net.toUri
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.ImageLoader
import coil3.disk.DiskCache
@@ -38,7 +41,8 @@ import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
-import com.vitorpamplona.amethyst.service.playback.VideoCache
+import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
+import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.ammolite.relays.NostrClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -173,6 +177,14 @@ class Amethyst : Application() {
}
}
+ fun createIntent(callbackUri: String): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(Intent.ACTION_VIEW, callbackUri.toUri(), this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
companion object {
lateinit var instance: Amethyst
private set
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt
index 6790b244a..c4a464811 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/MediaAspectRatioCache.kt
@@ -22,15 +22,25 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
-object MediaAspectRatioCache {
- val mediaAspectRatioCacheByUrl = LruCache(1000)
-
- fun get(url: String) = mediaAspectRatioCacheByUrl.get(url)
+interface MutableMediaAspectRatioCache {
+ fun get(url: String): Float
fun add(
url: String,
width: Int,
height: Int,
+ )
+}
+
+object MediaAspectRatioCache : MutableMediaAspectRatioCache {
+ val mediaAspectRatioCacheByUrl = LruCache(1000)
+
+ override fun get(url: String) = mediaAspectRatioCacheByUrl.get(url)
+
+ override fun add(
+ url: String,
+ width: Int,
+ height: Int,
) {
if (height > 1) {
mediaAspectRatioCacheByUrl.put(url, width.toFloat() / height.toFloat())
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt
deleted file mode 100644
index 80989981a..000000000
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * Copyright (c) 2024 Vitor Pamplona
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of
- * this software and associated documentation files (the "Software"), to deal in
- * the Software without restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
- * Software, and to permit persons to whom the Software is furnished to do so,
- * subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
- * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
- * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-package com.vitorpamplona.amethyst.service.playback
-
-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.PositionInfo
-import androidx.media3.common.Player.STATE_IDLE
-import androidx.media3.common.Player.STATE_READY
-import androidx.media3.common.VideoSize
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.session.MediaSession
-import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
-import com.vitorpamplona.amethyst.ui.MainActivity
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
-import kotlin.math.abs
-
-class MultiPlayerPlaybackManager(
- @UnstableApi
- val dataSourceFactory: CustomMediaSourceFactory,
- private val cachedPositions: VideoViewedPositionCache,
-) {
- // protects from LruCache killing playing sessions
- private val playingMap = mutableMapOf()
-
- private val cache =
- object : LruCache(20) { // 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 =
- 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 {
- setMediaSourceFactory(dataSourceFactory)
- build()
- }
-
- player.apply {
- repeatMode = Player.REPEAT_MODE_ALL
- videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
- volume = 0f
- }
-
- val mediaSession =
- MediaSession.Builder(context, player).run {
- callbackUri?.let { setSessionActivity(getCallbackIntent(it, applicationContext)) }
- setId(id)
- build()
- }
-
- player.addListener(
- object : Player.Listener {
- // avoids saving positions for live streams otherwise caching goes crazy
- val mustCachePositions = !uri.contains(".m3u8", true)
-
- override fun onVideoSizeChanged(videoSize: VideoSize) {
- MediaAspectRatioCache.add(uri, videoSize.width, videoSize.height)
- }
-
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- if (isPlaying) {
- player.setWakeMode(C.WAKE_MODE_NETWORK)
- playingMap.put(id, mediaSession)
- } else {
- player.setWakeMode(C.WAKE_MODE_NONE)
- if (mustCachePositions) {
- cachedPositions.add(uri, player.currentPosition)
- }
- cache.put(id, mediaSession)
- playingMap.remove(id, mediaSession)
- }
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- when (playbackState) {
- STATE_IDLE -> {
- // only saves if it wqs playing
- if (mustCachePositions && abs(player.currentPosition) > 1) {
- cachedPositions.add(uri, player.currentPosition)
- }
- }
- STATE_READY -> {
- if (mustCachePositions) {
- cachedPositions.get(uri)?.let { lastPosition ->
- if (abs(player.currentPosition - lastPosition) > 5 * 60) {
- player.seekTo(lastPosition)
- }
- }
- }
- }
- else -> {
- // only saves if it wqs playing
- if (mustCachePositions && abs(player.currentPosition) > 1) {
- cachedPositions.add(uri, player.currentPosition)
- }
- }
- }
- }
-
- override fun onPositionDiscontinuity(
- oldPosition: PositionInfo,
- newPosition: PositionInfo,
- reason: Int,
- ) {
- if (mustCachePositions && player.playbackState != STATE_IDLE) {
- cachedPositions.add(uri, newPosition.positionMs)
- }
- }
- },
- )
-
- cache.put(id, mediaSession)
-
- return mediaSession
- }
-
- @OptIn(DelicateCoroutinesApi::class)
- fun releaseAppPlayers() {
- GlobalScope.launch(Dispatchers.Main) {
- cache.evictAll()
- playingMap.forEach {
- it.value.player.release()
- it.value.release()
- }
- playingMap.clear()
- }
- }
-
- fun playingContent(): Collection = playingMap.values
-}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt
deleted file mode 100644
index 75718cc25..000000000
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Copyright (c) 2024 Vitor Pamplona
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of
- * this software and associated documentation files (the "Software"), to deal in
- * the Software without restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
- * Software, and to permit persons to whom the Software is furnished to do so,
- * subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
- * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
- * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-package com.vitorpamplona.amethyst.service.playback
-
-import android.content.ComponentName
-import android.content.Context
-import android.os.Bundle
-import android.util.Log
-import android.util.LruCache
-import androidx.media3.session.MediaController
-import androidx.media3.session.SessionToken
-import kotlinx.coroutines.CancellationException
-import java.util.concurrent.Executors
-
-object PlaybackClientController {
- var executorService = Executors.newCachedThreadPool()
- val cache = LruCache(1)
-
- fun prepareController(
- controllerID: String,
- videoUri: String,
- callbackUri: String?,
- proxyPort: Int? = 0,
- context: Context,
- onReady: (MediaController) -> Unit,
- ) {
- try {
- // creating a bundle object
- // creating a bundle object
- val bundle = Bundle()
- bundle.putString("id", controllerID)
- bundle.putString("uri", videoUri)
- bundle.putString("callbackUri", callbackUri)
- proxyPort?.let {
- bundle.putInt("proxyPort", it)
- }
-
- var session = cache.get(context.hashCode())
- if (session == null) {
- session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
- cache.put(context.hashCode(), session)
- }
-
- val controllerFuture =
- MediaController.Builder(context, session).setConnectionHints(bundle).buildAsync()
-
- controllerFuture.addListener(
- {
- try {
- onReady(controllerFuture.get())
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
- }
- },
- executorService,
- )
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
- }
- }
-}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt
deleted file mode 100644
index 6888b24ae..000000000
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Copyright (c) 2024 Vitor Pamplona
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of
- * this software and associated documentation files (the "Software"), to deal in
- * the Software without restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
- * Software, and to permit persons to whom the Software is furnished to do so,
- * subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
- * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
- * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-package com.vitorpamplona.amethyst.service.playback
-
-import android.content.Intent
-import android.util.Log
-import androidx.annotation.OptIn
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSessionService
-import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
-
-class PlaybackService : MediaSessionService() {
- private var videoViewedPositionCache = VideoViewedPositionCache()
- private var managerAllInOneNoProxy: MultiPlayerPlaybackManager? = null
- private var managerAllInOneProxy: MultiPlayerPlaybackManager? = null
-
- @OptIn(UnstableApi::class)
- fun lazyDS(proxyPort: Int): MultiPlayerPlaybackManager {
- if (proxyPort <= 0) {
- // no proxy
- managerAllInOneNoProxy?.let {
- return it
- }
-
- // creates new
- val okHttp = HttpClientManager.getHttpClient(false)
- val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
- managerAllInOneNoProxy = newInstance
- return newInstance
- } else {
- // with proxy, check if the port is the same.
- managerAllInOneProxy?.let {
- val okHttp = HttpClientManager.getHttpClient(true)
- if (okHttp == it.dataSourceFactory.okHttpClient.proxy) {
- return it
- }
-
- val toDestroyAllInOne = managerAllInOneProxy
-
- val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
-
- managerAllInOneProxy = newInstance
-
- toDestroyAllInOne?.releaseAppPlayers()
-
- return newInstance
- }
-
- // creates new
- val okHttp = HttpClientManager.getHttpClient(true)
- val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
- managerAllInOneProxy = newInstance
- return newInstance
- }
- }
-
- // Create your Player and MediaSession in the onCreate lifecycle event
- @OptIn(UnstableApi::class)
- override fun onCreate() {
- super.onCreate()
-
- Log.d("Lifetime Event", "PlaybackService.onCreate")
- }
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- super.onTaskRemoved(rootIntent)
-
- Log.d("Lifetime Event", "onTaskRemoved")
- }
-
- override fun onDestroy() {
- Log.d("Lifetime Event", "PlaybackService.onDestroy")
-
- managerAllInOneProxy?.releaseAppPlayers()
- managerAllInOneNoProxy?.releaseAppPlayers()
-
- super.onDestroy()
- }
-
- override fun onUpdateNotification(
- session: MediaSession,
- startInForegroundRequired: Boolean,
- ) {
- // Updates any new player ready
- super.onUpdateNotification(session, startInForegroundRequired)
-
- // Overrides the notification with any player actually playing
- managerAllInOneProxy?.playingContent()?.forEach {
- if (it.player.isPlaying) {
- super.onUpdateNotification(it, startInForegroundRequired)
- }
- }
-
- // Overrides again with playing with audio
- managerAllInOneProxy?.playingContent()?.forEach {
- if (it.player.isPlaying && it.player.volume > 0) {
- super.onUpdateNotification(it, startInForegroundRequired)
- }
- }
-
- // Overrides the notification with any player actually playing
- managerAllInOneNoProxy?.playingContent()?.forEach {
- if (it.player.isPlaying) {
- super.onUpdateNotification(it, startInForegroundRequired)
- }
- }
-
- // Overrides again with playing with audio
- managerAllInOneNoProxy?.playingContent()?.forEach {
- if (it.player.isPlaying && it.player.volume > 0) {
- super.onUpdateNotification(it, 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 proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
-
- val manager = lazyDS(proxyPort)
-
- return manager.getMediaSession(
- id,
- uri,
- callbackUri,
- context = this,
- applicationContext = applicationContext,
- )
- }
-}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt
new file mode 100644
index 000000000..13dc54777
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt
@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient.removeController
+import kotlinx.coroutines.flow.MutableStateFlow
+
+object BackgroundMedia {
+ // background playing mutex.
+ val bgInstance = MutableStateFlow(null)
+
+ private fun hasInstance() = bgInstance.value != null
+
+ private fun isComposed() = bgInstance.value?.composed?.value == true
+
+ private fun isUri(videoUri: String): Boolean = videoUri == bgInstance.value?.currrentMedia()
+
+ fun isPlaying() = bgInstance.value?.isPlaying() == true
+
+ fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id
+
+ fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState)
+
+ fun backgroundOrNewController(videoUri: String): MediaControllerState {
+ // allows only the first composable with the url of the video to match.
+ return if (isUri(videoUri) && !isComposed()) {
+ bgInstance.value ?: MediaControllerState()
+ } else {
+ MediaControllerState()
+ }
+ }
+
+ fun removeBackgroundControllerAndReleaseIt() {
+ bgInstance.value?.let {
+ removeController(it)
+ bgInstance.tryEmit(null)
+ }
+ }
+
+ fun removeBackgroundControllerIfNotComposed() {
+ bgInstance.value?.let {
+ if (!it.composed.value) {
+ removeController(it)
+ }
+ bgInstance.tryEmit(null)
+ }
+ }
+
+ fun switchKeepPlaying(mediaControllerState: MediaControllerState) {
+ if (hasInstance() && !isMutex(mediaControllerState)) {
+ removeBackgroundControllerIfNotComposed()
+ }
+ bgInstance.tryEmit(mediaControllerState)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt
new file mode 100644
index 000000000..7a7dc717a
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.ui.platform.LocalView
+import androidx.media3.common.Player
+
+@Composable
+fun ControlWhenPlayerIsActive(
+ mediaControllerState: MediaControllerState,
+ automaticallyStartPlayback: State,
+ isClosestToTheCenterOfTheScreen: MutableState,
+) {
+ val controller = mediaControllerState.controller.value ?: return
+
+ LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) {
+ // active means being fully visible
+ if (isClosestToTheCenterOfTheScreen.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 (BackgroundMedia.hasBackgroundButNot(mediaControllerState)) {
+ 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 (!mediaControllerState.keepPlaying.value) {
+ controller.pause()
+ }
+ }
+ }
+
+ val view = LocalView.current
+
+ // Keeps the screen on while playing and viewing videos.
+ DisposableEffect(key1 = controller, key2 = view) {
+ 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.
+ if (view.keepScreenOn != isPlaying) {
+ view.keepScreenOn = isPlaying
+ }
+ }
+ }
+
+ controller.addListener(listener)
+ onDispose {
+ if (view.keepScreenOn) {
+ view.keepScreenOn = false
+ }
+ controller.removeListener(listener)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt
new file mode 100644
index 000000000..200f72c52
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt
@@ -0,0 +1,244 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+
+@Composable
+fun GetVideoController(
+ mediaItem: State,
+ videoUri: String,
+ proxyPort: Int?,
+ muted: Boolean = false,
+ inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
+) {
+ val context = LocalContext.current
+
+ val onlyOnePreparing = AtomicBoolean()
+
+ val controllerId = remember(videoUri) { BackgroundMedia.backgroundOrNewController(videoUri) }
+
+ controllerId.composed.value = true
+
+ val scope = rememberCoroutineScope()
+
+ // Prepares a VideoPlayer from the foreground service.
+ DisposableEffect(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 (controllerId.needsController()) {
+ // If there is a connection, don't wait.
+ if (!onlyOnePreparing.getAndSet(true)) {
+ scope.launch {
+ Log.d("PlaybackService", "Preparing Video ${controllerId.id} $videoUri")
+ PlaybackServiceClient.prepareController(
+ controllerId,
+ videoUri,
+ proxyPort,
+ context,
+ ) { controllerId ->
+ scope.launch(Dispatchers.Main) {
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ onlyOnePreparing.getAndSet(false)
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+
+ // REQUIRED TO BE RUN IN THE MAIN THREAD
+ if (!controllerId.isPlaying()) {
+ if (BackgroundMedia.isPlaying()) {
+ // There is a video playing, start this one on mute.
+ controllerId.controller.value?.volume = 0f
+ } else {
+ // There is no other video playing. Use the default mute state to
+ // decide if sound is on or not.
+ controllerId.controller.value?.volume = if (muted) 0f else 1f
+ }
+ }
+
+ controllerId.controller.value?.setMediaItem(mediaItem.value)
+ controllerId.controller.value?.prepare()
+
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+
+ controllerId.readyToDisplay.value = true
+
+ onlyOnePreparing.getAndSet(false)
+
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+ }
+ }
+ }
+ }
+ } else {
+ // has been loaded. prepare to play. This happens when the background video switches screens.
+ controllerId.controller.value?.let {
+ scope.launch {
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+
+ if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
+ Log.d("PlaybackService", "Preparing Existing Video $videoUri ")
+
+ 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 (muted) 0f else 1f
+ }
+
+ if (mediaItem.value != it.currentMediaItem) {
+ it.setMediaItem(mediaItem.value)
+ }
+
+ it.prepare()
+
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+ }
+ }
+ }
+ }
+
+ onDispose {
+ controllerId.composed.value = false
+ if (!controllerId.keepPlaying.value) {
+ PlaybackServiceClient.removeController(controllerId)
+ }
+ }
+ }
+
+ // User pauses and resumes the app. What to do with videos?
+ val lifeCycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(key1 = lifeCycleOwner) {
+ val observer =
+ LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ controllerId.composed.value = true
+ // 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 (controllerId.controller.value == null) {
+ if (!onlyOnePreparing.getAndSet(true)) {
+ scope.launch(Dispatchers.Main) {
+ Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} $videoUri ")
+ PlaybackServiceClient.prepareController(
+ controllerId,
+ videoUri,
+ proxyPort,
+ context,
+ ) { controllerId ->
+ scope.launch(Dispatchers.Main) {
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ onlyOnePreparing.getAndSet(false)
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+
+ // REQUIRED TO BE RUN IN THE MAIN THREAD
+ // checks again to make sure no other thread has created a controller.
+ if (!controllerId.isPlaying()) {
+ if (BackgroundMedia.isPlaying()) {
+ // There is a video playing, start this one on mute.
+ controllerId.controller.value?.volume = 0f
+ } else {
+ // There is no other video playing. Use the default mute state to
+ // decide if sound is on or not.
+ controllerId.controller.value?.volume = if (muted) 0f else 1f
+ }
+ }
+
+ controllerId.controller.value?.setMediaItem(mediaItem.value)
+ controllerId.controller.value?.prepare()
+
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ onlyOnePreparing.getAndSet(false)
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+
+ controllerId.readyToDisplay.value = true
+
+ onlyOnePreparing.getAndSet(false)
+
+ // checks if the player is still active after requesting to load
+ if (!controllerId.isActive()) {
+ PlaybackServiceClient.removeController(controllerId)
+ return@launch
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if (event == Lifecycle.Event.ON_PAUSE) {
+ controllerId.composed.value = false
+ if (!controllerId.keepPlaying.value) {
+ // Stops and releases the media.
+ PlaybackServiceClient.removeController(controllerId)
+ }
+ }
+ }
+
+ lifeCycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ if (controllerId.readyToDisplay.value && controllerId.active.value) {
+ controllerId.controller.value?.let {
+ inner(controllerId)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt
new file mode 100644
index 000000000..9ce265960
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import android.graphics.drawable.Drawable
+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.setValue
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+
+@Composable
+fun LoadThumbAndThenVideoView(
+ videoUri: String,
+ mimeType: String?,
+ title: String? = null,
+ thumbUri: String,
+ authorName: String? = null,
+ roundedCorner: Boolean,
+ contentScale: ContentScale,
+ nostrUriCallback: String? = null,
+ accountViewModel: AccountViewModel,
+ onDialog: ((Boolean) -> Unit)? = null,
+) {
+ var loadingFinished by remember { mutableStateOf>(Pair(false, null)) }
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ accountViewModel.loadThumb(
+ context,
+ thumbUri,
+ onReady = {
+ loadingFinished =
+ if (it != null) {
+ Pair(true, it)
+ } else {
+ Pair(true, null)
+ }
+ },
+ onError = { loadingFinished = Pair(true, null) },
+ )
+ }
+
+ if (loadingFinished.first) {
+ if (loadingFinished.second != null) {
+ VideoView(
+ videoUri = videoUri,
+ mimeType = mimeType,
+ title = title,
+ thumb = VideoThumb(loadingFinished.second),
+ roundedCorner = roundedCorner,
+ contentScale = contentScale,
+ artworkUri = thumbUri,
+ authorName = authorName,
+ nostrUriCallback = nostrUriCallback,
+ accountViewModel = accountViewModel,
+ onDialog = onDialog,
+ )
+ } else {
+ VideoView(
+ videoUri = videoUri,
+ mimeType = mimeType,
+ title = title,
+ thumb = null,
+ roundedCorner = roundedCorner,
+ contentScale = contentScale,
+ artworkUri = thumbUri,
+ authorName = authorName,
+ nostrUriCallback = nostrUriCallback,
+ accountViewModel = accountViewModel,
+ onDialog = onDialog,
+ )
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt
new file mode 100644
index 000000000..1ece014c0
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateOf
+import androidx.media3.session.MediaController
+import java.util.UUID
+
+@Stable
+class MediaControllerState(
+ // each composable has an ID.
+ val id: String = UUID.randomUUID().toString(),
+ // This is filled after the controller returns from this class
+ val controller: MutableState = mutableStateOf(null),
+ // this set's the stage to keep playing on the background or not when the user leaves the screen
+ val keepPlaying: MutableState = mutableStateOf(false),
+ // this will be false if the screen leaves before the controller connection comes back from the service.
+ val active: MutableState = mutableStateOf(true),
+ // this will be set to own when the controller is ready
+ val readyToDisplay: MutableState = mutableStateOf(false),
+ // isCurrentlyBeingRendered
+ val composed: MutableState = mutableStateOf(false),
+) {
+ fun isPlaying() = controller.value?.isPlaying == true
+
+ fun currrentMedia() = controller.value?.currentMediaItem?.mediaId
+
+ fun isActive() = active.value == true
+
+ fun needsController() = controller.value == null
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt
new file mode 100644
index 000000000..5e9499f9e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/PlayerSurface.kt
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import android.view.Surface
+import android.view.SurfaceView
+import android.view.TextureView
+import androidx.annotation.IntDef
+import androidx.compose.foundation.AndroidEmbeddedExternalSurface
+import androidx.compose.foundation.AndroidExternalSurface
+import androidx.compose.foundation.AndroidExternalSurfaceScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.media3.common.Player
+
+/**
+ * Provides a dedicated drawing [Surface] for media playbacks using a [Player].
+ *
+ * The player's video output is displayed with either a [SurfaceView]/[AndroidExternalSurface] or a
+ * [TextureView]/[AndroidEmbeddedExternalSurface].
+ *
+ * [Player] takes care of attaching the rendered output to the [Surface] and clearing it, when it is
+ * destroyed.
+ *
+ * See
+ * [Choosing a surface type](https://developer.android.com/media/media3/ui/playerview#surfacetype)
+ * for more information.
+ */
+@Composable
+fun PlayerSurface(
+ player: Player,
+ surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
+ modifier: Modifier = Modifier,
+) {
+ val onSurfaceCreated: (Surface) -> Unit = { surface -> player.setVideoSurface(surface) }
+ val onSurfaceDestroyed: () -> Unit = { player.setVideoSurface(null) }
+ val onSurfaceInitialized: AndroidExternalSurfaceScope.() -> Unit = {
+ onSurface { surface, _, _ ->
+ onSurfaceCreated(surface)
+ surface.onDestroyed { onSurfaceDestroyed() }
+ }
+ }
+
+ when (surfaceType) {
+ SURFACE_TYPE_SURFACE_VIEW ->
+ AndroidExternalSurface(modifier = modifier, onInit = onSurfaceInitialized)
+ SURFACE_TYPE_TEXTURE_VIEW ->
+ AndroidEmbeddedExternalSurface(modifier = modifier, onInit = onSurfaceInitialized)
+ else -> throw IllegalArgumentException("Unrecognized surface type: $surfaceType")
+ }
+}
+
+/**
+ * The type of surface view used for media playbacks. One of [SURFACE_TYPE_SURFACE_VIEW] or
+ * [SURFACE_TYPE_TEXTURE_VIEW].
+ */
+@MustBeDocumented
+@Retention(AnnotationRetention.SOURCE)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE, AnnotationTarget.TYPE_PARAMETER)
+@IntDef(SURFACE_TYPE_SURFACE_VIEW, SURFACE_TYPE_TEXTURE_VIEW)
+annotation class SurfaceType
+
+/** Surface type equivalent to [SurfaceView] . */
+const val SURFACE_TYPE_SURFACE_VIEW = 1
+
+/** Surface type equivalent to [TextureView]. */
+const val SURFACE_TYPE_TEXTURE_VIEW = 2
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt
new file mode 100644
index 000000000..e570a92ff
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt
@@ -0,0 +1,122 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import android.content.Context
+import android.view.View
+import androidx.annotation.OptIn
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControls
+import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
+
+@Composable
+@OptIn(UnstableApi::class)
+fun RenderVideoPlayer(
+ videoUri: String,
+ mimeType: String?,
+ controller: MediaControllerState,
+ thumbData: VideoThumb?,
+ showControls: Boolean = true,
+ contentScale: ContentScale,
+ nostrUriCallback: String?,
+ waveform: WaveformTag? = null,
+ borderModifier: Modifier,
+ videoModifier: Modifier,
+ onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
+ onDialog: ((Boolean) -> Unit)?,
+ accountViewModel: AccountViewModel,
+) {
+ val controllerVisible = remember(controller) { mutableStateOf(false) }
+
+ Box(modifier = borderModifier) {
+ AndroidView(
+ modifier = videoModifier,
+ factory = { context: Context ->
+ PlayerView(context).apply {
+ player = controller.controller.value
+ setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
+ setBackgroundColor(Color.Transparent.toArgb())
+ setShutterBackgroundColor(Color.Transparent.toArgb())
+
+ controllerAutoShow = false
+ useController = showControls
+ thumbData?.thumb?.let { defaultArtwork = it }
+ hideController()
+
+ resizeMode =
+ when (contentScale) {
+ ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
+ ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
+ ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
+ ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
+ ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
+ }
+
+ if (showControls) {
+ onDialog?.let { innerOnDialog ->
+ setFullscreenButtonClickListener {
+ controller.controller.value?.pause()
+ innerOnDialog(it)
+ }
+ }
+ setControllerVisibilityListener(
+ PlayerView.ControllerVisibilityListener { visible ->
+ controllerVisible.value = visible == View.VISIBLE
+ onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
+ },
+ )
+ }
+ }
+ },
+ )
+
+ waveform?.let { Waveform(it, controller, Modifier.align(Alignment.Center)) }
+
+ if (showControls) {
+ RenderControls(
+ videoUri,
+ mimeType,
+ controller,
+ nostrUriCallback,
+ controllerVisible,
+ Modifier.align(Alignment.TopEnd),
+ accountViewModel,
+ )
+ } else {
+ controller.controller.value?.volume = 0f
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt
similarity index 76%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt
index 615990480..2d3759abc 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoViewedPositionCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoThumb.kt
@@ -18,19 +18,12 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.service.playback
+package com.vitorpamplona.amethyst.service.playback.composable
-import android.util.LruCache
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Stable
-class VideoViewedPositionCache {
- val cachedPosition = LruCache(100)
-
- fun add(
- uri: String,
- position: Long,
- ) {
- cachedPosition.put(uri, position)
- }
-
- fun get(uri: String): Long? = cachedPosition.get(uri)
-}
+@Stable
+data class VideoThumb(
+ val thumb: Drawable?,
+)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt
new file mode 100644
index 000000000..37197b346
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt
@@ -0,0 +1,189 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
+import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
+import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
+import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size75dp
+import com.vitorpamplona.amethyst.ui.theme.imageModifier
+import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
+import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
+import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
+
+@Composable
+fun VideoView(
+ videoUri: String,
+ mimeType: String?,
+ title: String? = null,
+ thumb: VideoThumb? = null,
+ roundedCorner: Boolean,
+ gallery: Boolean = false,
+ contentScale: ContentScale,
+ waveform: WaveformTag? = null,
+ artworkUri: String? = null,
+ authorName: String? = null,
+ dimensions: DimensionTag? = null,
+ blurhash: String? = null,
+ nostrUriCallback: String? = null,
+ onDialog: ((Boolean) -> Unit)? = null,
+ onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
+ accountViewModel: AccountViewModel,
+ alwaysShowVideo: Boolean = false,
+) {
+ val borderModifier =
+ if (roundedCorner) {
+ MaterialTheme.colorScheme.imageModifier
+ } else if (gallery) {
+ MaterialTheme.colorScheme.videoGalleryModifier
+ } else {
+ Modifier
+ }
+
+ VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
+}
+
+@Composable
+fun VideoView(
+ videoUri: String,
+ mimeType: String?,
+ title: String? = null,
+ thumb: VideoThumb? = null,
+ borderModifier: Modifier,
+ contentScale: ContentScale,
+ waveform: WaveformTag? = null,
+ artworkUri: String? = null,
+ authorName: String? = null,
+ dimensions: DimensionTag? = null,
+ blurhash: String? = null,
+ nostrUriCallback: String? = null,
+ onDialog: ((Boolean) -> Unit)? = null,
+ onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
+ accountViewModel: AccountViewModel,
+ alwaysShowVideo: Boolean = false,
+ showControls: Boolean = true,
+) {
+ val automaticallyStartPlayback =
+ remember {
+ mutableStateOf(
+ if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value,
+ )
+ }
+
+ if (blurhash == null) {
+ val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
+
+ val modifier =
+ if (ratio != null && automaticallyStartPlayback.value) {
+ Modifier.aspectRatio(ratio)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
+ if (!automaticallyStartPlayback.value) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
+ }
+ } else {
+ VideoViewInner(
+ videoUri = videoUri,
+ mimeType = mimeType,
+ title = title,
+ thumb = thumb,
+ borderModifier = borderModifier,
+ contentScale = contentScale,
+ waveform = waveform,
+ artworkUri = artworkUri,
+ authorName = authorName,
+ nostrUriCallback = nostrUriCallback,
+ automaticallyStartPlayback = automaticallyStartPlayback,
+ onControllerVisibilityChanged = onControllerVisibilityChanged,
+ onDialog = onDialog,
+ accountViewModel = accountViewModel,
+ showControls = showControls,
+ )
+ }
+ }
+ } else {
+ val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
+
+ val modifier =
+ if (ratio != null) {
+ Modifier.aspectRatio(ratio)
+ } else {
+ Modifier
+ }
+
+ Box(modifier, contentAlignment = Alignment.Center) {
+ // Always displays Blurharh to avoid size flickering
+ DisplayBlurHash(
+ blurhash,
+ null,
+ contentScale,
+ if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
+ )
+
+ if (!automaticallyStartPlayback.value) {
+ IconButton(
+ modifier = Modifier.size(Size75dp),
+ onClick = { automaticallyStartPlayback.value = true },
+ ) {
+ DownloadForOfflineIcon(Size75dp, Color.White)
+ }
+ } else {
+ VideoViewInner(
+ videoUri = videoUri,
+ mimeType = mimeType,
+ title = title,
+ thumb = thumb,
+ borderModifier = borderModifier,
+ contentScale = contentScale,
+ waveform = waveform,
+ artworkUri = artworkUri,
+ authorName = authorName,
+ nostrUriCallback = nostrUriCallback,
+ automaticallyStartPlayback = automaticallyStartPlayback,
+ onControllerVisibilityChanged = onControllerVisibilityChanged,
+ onDialog = onDialog,
+ accountViewModel = accountViewModel,
+ showControls = showControls,
+ )
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt
new file mode 100644
index 000000000..d34acc377
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
+import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
+import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
+
+public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
+
+@Composable
+fun VideoViewInner(
+ videoUri: String,
+ mimeType: String?,
+ title: String? = null,
+ thumb: VideoThumb? = null,
+ showControls: Boolean = true,
+ contentScale: ContentScale,
+ borderModifier: Modifier,
+ waveform: WaveformTag? = null,
+ artworkUri: String? = null,
+ authorName: String? = null,
+ nostrUriCallback: String? = null,
+ automaticallyStartPlayback: State,
+ onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
+ onDialog: ((Boolean) -> Unit)? = null,
+ accountViewModel: AccountViewModel,
+) {
+ // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
+ val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
+
+ GetMediaItem(videoUri, title, artworkUri, authorName, nostrUriCallback) { mediaItem ->
+ GetVideoController(
+ mediaItem = mediaItem,
+ videoUri = videoUri,
+ muted = muted,
+ proxyPort =
+ HttpClientManager.getCurrentProxyPort(
+ accountViewModel.account.shouldUseTorForVideoDownload(videoUri),
+ ),
+ ) { controller ->
+ VideoPlayerActiveMutex(controller.id) { videoModifier, isClosestToTheCenterOfTheScreen ->
+ ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
+ RenderVideoPlayer(
+ videoUri = videoUri,
+ mimeType = mimeType,
+ controller = controller,
+ thumbData = thumb,
+ showControls = showControls,
+ contentScale = contentScale,
+ nostrUriCallback = nostrUriCallback,
+ waveform = waveform,
+ borderModifier = borderModifier,
+ videoModifier = videoModifier,
+ onControllerVisibilityChanged = onControllerVisibilityChanged,
+ onDialog = onDialog,
+ accountViewModel = accountViewModel,
+ )
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt
new file mode 100644
index 000000000..b7ef1c84c
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/KeepPlayingButton.kt
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.controls
+
+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.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import com.vitorpamplona.amethyst.ui.note.LyricsIcon
+import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
+import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
+import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
+import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
+
+@Composable
+fun KeepPlayingButton(
+ keepPlayingStart: MutableState,
+ controllerVisible: MutableState,
+ modifier: Modifier,
+ toggle: (Boolean) -> Unit,
+) {
+ val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
+
+ AnimatedVisibility(
+ visible = controllerVisible.value,
+ modifier = modifier,
+ enter = remember { fadeIn() },
+ exit = remember { fadeOut() },
+ ) {
+ Box(modifier = PinBottomIconSize) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colorScheme.background),
+ )
+
+ IconButton(
+ onClick = {
+ keepPlaying.value = !keepPlaying.value
+ toggle(keepPlaying.value)
+ },
+ modifier = Size50Modifier,
+ ) {
+ if (keepPlaying.value) {
+ LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
+ } else {
+ LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
+ }
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt
new file mode 100644
index 000000000..a2f796eba
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt
@@ -0,0 +1,126 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.controls
+
+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.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
+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
+
+@Composable
+fun MuteButton(
+ controllerVisible: MutableState,
+ startingMuteState: Boolean,
+ modifier: Modifier,
+ toggle: (Boolean) -> Unit,
+) {
+ val holdOn =
+ remember {
+ mutableStateOf(
+ true,
+ )
+ }
+
+ LaunchedEffect(key1 = controllerVisible) {
+ launch(Dispatchers.Default) {
+ delay(2000)
+ holdOn.value = false
+ }
+ }
+
+ val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
+
+ AnimatedVisibility(
+ visible = holdOn.value || controllerVisible.value,
+ modifier = modifier,
+ enter = remember { fadeIn() },
+ exit = remember { fadeOut() },
+ ) {
+ Box(modifier = VolumeBottomIconSize) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colorScheme.background),
+ )
+
+ IconButton(
+ onClick = {
+ mutedInstance.value = !mutedInstance.value
+ toggle(mutedInstance.value)
+ },
+ modifier = Size50Modifier,
+ ) {
+ if (mutedInstance.value) {
+ MutedIcon()
+ } else {
+ MuteIcon()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun MutedIcon() {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.VolumeOff,
+ contentDescription = stringRes(id = R.string.muted_button),
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier = Size30Modifier,
+ )
+}
+
+@Composable
+fun MuteIcon() {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = stringRes(id = R.string.mute_button),
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier = Size30Modifier,
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt
new file mode 100644
index 000000000..6257f9a21
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControls.kt
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.controls
+
+import android.content.Context
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia
+import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
+import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
+import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
+import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
+import com.vitorpamplona.amethyst.ui.components.ShareImageAction
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.theme.Size110dp
+import com.vitorpamplona.amethyst.ui.theme.Size165dp
+import com.vitorpamplona.amethyst.ui.theme.Size55dp
+
+@Composable
+fun RenderControls(
+ videoUri: String,
+ mimeType: String?,
+ controllerState: MediaControllerState,
+ nostrUriCallback: String?,
+ controllerVisible: MutableState,
+ buttonPositionModifier: Modifier,
+ accountViewModel: AccountViewModel,
+) {
+ MuteButton(
+ controllerVisible,
+ (controllerState.controller.value?.volume ?: 0f) < 0.001,
+ buttonPositionModifier,
+ ) { mute: Boolean ->
+ // makes the new setting the default for new creations.
+ DEFAULT_MUTED_SETTING.value = mute
+
+ // if the user unmutes a video and it's not the current playing, switches to that one.
+ if (!mute && BackgroundMedia.hasBackgroundButNot(controllerState)) {
+ BackgroundMedia.removeBackgroundControllerIfNotComposed()
+ }
+
+ controllerState.controller.value?.volume = if (mute) 0f else 1f
+ }
+
+ KeepPlayingButton(
+ controllerState.keepPlaying,
+ controllerVisible,
+ buttonPositionModifier.padding(end = Size55dp),
+ ) { newKeepPlaying: Boolean ->
+ // If something else is playing and the user marks this video to keep playing, stops the other
+ // one.
+ if (newKeepPlaying) {
+ BackgroundMedia.switchKeepPlaying(controllerState)
+ } else {
+ // if removed from background.
+ if (BackgroundMedia.isMutex(controllerState)) {
+ BackgroundMedia.removeBackgroundControllerIfNotComposed()
+ }
+ }
+
+ controllerState.keepPlaying.value = newKeepPlaying
+ }
+
+ if (!isLiveStreaming(videoUri)) {
+ AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context ->
+ saveMediaToGallery(videoUri, mimeType, context, accountViewModel)
+ }
+
+ AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle ->
+ ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
+ }
+ } else {
+ AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle ->
+ ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
+ }
+ }
+}
+
+private fun saveMediaToGallery(
+ videoUri: String?,
+ mimeType: String?,
+ localContext: Context,
+ accountViewModel: AccountViewModel,
+) {
+ MediaSaverToDisk.saveDownloadingIfNeeded(
+ videoUri = videoUri,
+ forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(),
+ mimeType = mimeType,
+ localContext = localContext,
+ onSuccess = {
+ accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
+ },
+ onError = {
+ accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it)
+ },
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt
new file mode 100644
index 000000000..12e48a608
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveButton.kt
@@ -0,0 +1,122 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.controls
+
+import android.Manifest
+import android.content.Context
+import android.os.Build
+import android.widget.Toast
+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.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
+import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
+import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
+import kotlinx.coroutines.launch
+
+@Composable
+fun AnimatedSaveButton(
+ controllerVisible: State,
+ modifier: Modifier,
+ onSaveClick: (localContext: Context) -> Unit,
+) {
+ AnimatedVisibility(
+ visible = controllerVisible.value,
+ modifier = modifier,
+ enter = remember { fadeIn() },
+ exit = remember { fadeOut() },
+ ) {
+ SaveButton(onSaveClick)
+ }
+}
+
+@kotlin.OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
+ Box(modifier = PinBottomIconSize) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colorScheme.background),
+ )
+
+ val localContext = LocalContext.current
+
+ val writeStoragePermissionState =
+ rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
+ if (isGranted) {
+ onSaveClick(localContext)
+ }
+ }
+ val scope = rememberCoroutineScope()
+ IconButton(
+ onClick = {
+ scope.launch {
+ Toast
+ .makeText(
+ localContext,
+ stringRes(localContext, R.string.video_download_has_started_toast),
+ Toast.LENGTH_SHORT,
+ ).show()
+ }
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
+ writeStoragePermissionState.status.isGranted
+ ) {
+ onSaveClick(localContext)
+ } else {
+ writeStoragePermissionState.launchPermissionRequest()
+ }
+ },
+ modifier = Size50Modifier,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ modifier = Size20Modifier,
+ contentDescription = stringRes(R.string.save_to_gallery),
+ )
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt
new file mode 100644
index 000000000..f099e1ad7
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt
@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.controls
+
+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.fillMaxSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
+import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
+import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
+
+@Composable
+fun AnimatedShareButton(
+ controllerVisible: State,
+ modifier: Modifier,
+ innerAction: @Composable (MutableState, () -> Unit) -> Unit,
+) {
+ AnimatedVisibility(
+ visible = controllerVisible.value,
+ modifier = modifier,
+ enter = remember { fadeIn() },
+ exit = remember { fadeOut() },
+ ) {
+ ShareButton(innerAction)
+ }
+}
+
+@Composable
+fun ShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) {
+ Box(modifier = PinBottomIconSize) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colorScheme.background),
+ )
+
+ val popupExpanded = remember { mutableStateOf(false) }
+
+ IconButton(
+ onClick = {
+ popupExpanded.value = true
+ },
+ modifier = Size50Modifier,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ modifier = Size20Modifier,
+ contentDescription = stringRes(R.string.share_or_save),
+ )
+
+ innerAction(popupExpanded) { popupExpanded.value = false }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt
new file mode 100644
index 000000000..de1296ac5
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.mainVideo
+
+import android.graphics.Rect
+import android.view.View
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+
+// This keeps the position of all visible videos in the current screen.
+val trackingVideos = mutableListOf()
+
+@Stable
+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 VideoPlayerActiveMutex(
+ controller: String,
+ inner: @Composable (Modifier, MutableState) -> Unit,
+) {
+ val myCache = remember(controller) { VisibilityData() }
+
+ // Is the current video the closest to the center?
+ val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) }
+
+ // Keep track of all available videos.
+ DisposableEffect(key1 = controller) {
+ trackingVideos.add(myCache)
+ onDispose { trackingVideos.remove(myCache) }
+ }
+
+ val videoModifier =
+ remember(controller) {
+ Modifier.fillMaxWidth().heightIn(min = 100.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 (isClosestToTheCenterOfTheScreen.value != newActive) {
+ isClosestToTheCenterOfTheScreen.value = newActive
+ }
+ } else {
+ // got out of screen, marks video as inactive
+ if (isClosestToTheCenterOfTheScreen.value) {
+ isClosestToTheCenterOfTheScreen.value = false
+ }
+ }
+ }
+ }
+
+ inner(videoModifier, isClosestToTheCenterOfTheScreen)
+}
+
+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
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt
new file mode 100644
index 000000000..9f3802a32
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/GetMediaItem.kt
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.media3.common.MediaItem
+import com.vitorpamplona.amethyst.commons.compose.produceCachedState
+
+val mediaItemCache = MediaItemCache()
+
+@Composable
+fun GetMediaItem(
+ videoUri: String,
+ title: String?,
+ artworkUri: String?,
+ authorName: String?,
+ callbackUri: String?,
+ inner: @Composable (State) -> Unit,
+) {
+ val data =
+ remember(videoUri) {
+ MediaItemData(
+ videoUri = videoUri,
+ authorName = authorName,
+ title = title,
+ artworkUri = artworkUri,
+ callbackUri = callbackUri,
+ )
+ }
+
+ GetMediaItem(data, inner)
+}
+
+@Composable
+fun GetMediaItem(
+ data: MediaItemData,
+ inner: @Composable (State) -> Unit,
+) {
+ val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
+
+ mediaItem?.let {
+ val myState = remember(data.videoUri) { mutableStateOf(it) }
+ inner(myState)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt
new file mode 100644
index 000000000..06097ec37
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemCache.kt
@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
+
+import android.os.Bundle
+import androidx.core.net.toUri
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
+import kotlin.coroutines.cancellation.CancellationException
+
+class MediaItemCache : GenericBaseCache(20) {
+ override suspend fun compute(key: MediaItemData): MediaItem =
+ MediaItem
+ .Builder()
+ .setMediaId(key.videoUri)
+ .setUri(key.videoUri)
+ .setMediaMetadata(
+ MediaMetadata
+ .Builder()
+ .setArtist(key.authorName?.ifBlank { null })
+ .setTitle(key.title?.ifBlank { null } ?: key.videoUri)
+ .setExtras(
+ Bundle().apply {
+ putString("callbackUri", key.callbackUri)
+ },
+ ).setArtworkUri(
+ try {
+ key.artworkUri?.toUri()
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ null
+ },
+ ).build(),
+ ).build()
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt
new file mode 100644
index 000000000..e342fe7be
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mediaitem/MediaItemData.kt
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+data class MediaItemData(
+ val videoUri: String,
+ val authorName: String? = null,
+ val title: String? = null,
+ val artworkUri: String? = null,
+ val callbackUri: String? = null,
+)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt
new file mode 100644
index 000000000..9498d3201
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.composable.wavefront
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableFloatState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.media3.common.Player
+import androidx.media3.session.MediaController
+import com.linc.audiowaveform.infiniteLinearGradient
+import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
+import com.vitorpamplona.amethyst.ui.components.AudioWaveformReadOnly
+import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flow
+
+@Composable
+fun Waveform(
+ waveform: WaveformTag,
+ mediaControllerState: MediaControllerState,
+ modifier: Modifier,
+) {
+ val waveformProgress = remember { mutableFloatStateOf(0F) }
+
+ DrawWaveform(waveform, waveformProgress, modifier)
+
+ val restartFlow = remember { mutableIntStateOf(0) }
+
+ // Keeps the screen on while playing and viewing videos.
+ DisposableEffect(key1 = mediaControllerState.controller.value) {
+ 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.
+ if (isPlaying) {
+ restartFlow.intValue += 1
+ }
+ }
+ }
+
+ mediaControllerState.controller.value?.addListener(listener)
+ onDispose { mediaControllerState.controller.value?.removeListener(listener) }
+ }
+
+ LaunchedEffect(key1 = restartFlow.intValue) {
+ mediaControllerState.controller.value?.let {
+ pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value }
+ }
+ }
+}
+
+private fun pollCurrentDuration(controller: MediaController) =
+ flow {
+ while (controller.currentPosition <= controller.duration) {
+ emit(controller.currentPosition / controller.duration.toFloat())
+ delay(100)
+ }
+ }.conflate()
+
+@Composable
+fun DrawWaveform(
+ waveform: WaveformTag,
+ waveformProgress: MutableFloatState,
+ modifier: Modifier,
+) {
+ AudioWaveformReadOnly(
+ modifier = modifier.padding(start = 10.dp, end = 10.dp),
+ amplitudes = waveform.wave,
+ progress = waveformProgress.floatValue,
+ progressBrush =
+ Brush.infiniteLinearGradient(
+ colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
+ animation = tween(durationMillis = 6000, easing = LinearEasing),
+ width = 128F,
+ ),
+ onProgressChange = { waveformProgress.floatValue = it },
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt
new file mode 100644
index 000000000..e91f33523
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/IsLiveStreaming.kt
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.diskCache
+
+fun isLiveStreaming(url: String) = url.contains(".m3u8", true)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt
similarity index 70%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt
index dd41a05e4..357f59288 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.service.playback
+package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint
import android.content.Context
@@ -32,9 +32,29 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
@SuppressLint("UnsafeOptInUsageError")
class VideoCache {
- var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
+ var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 150MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt
similarity index 82%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt
index 2de2f4b1b..93f3188b0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.service.playback
+package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
@@ -28,6 +28,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import okhttp3.OkHttpClient
/**
@@ -35,10 +36,15 @@ import okhttp3.OkHttpClient
*/
@UnstableApi
class CustomMediaSourceFactory(
- val okHttpClient: OkHttpClient,
+ okHttpClient: OkHttpClient,
) : MediaSource.Factory {
- private var cachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(okHttpClient))
- private var nonCachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
+ private var cachingFactory: MediaSource.Factory =
+ DefaultMediaSourceFactory(
+ Amethyst.Companion.instance.videoCache
+ .get(okHttpClient),
+ )
+ private var nonCachingFactory: MediaSource.Factory =
+ DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
@@ -55,7 +61,7 @@ class CustomMediaSourceFactory(
override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
- if (mediaItem.mediaId.contains(".m3u8", true)) {
+ if (isLiveStreaming(mediaItem.mediaId)) {
return nonCachingFactory.createMediaSource(mediaItem)
}
return cachingFactory.createMediaSource(mediaItem)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt
new file mode 100644
index 000000000..d2643927f
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
+import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
+import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
+import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
+import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
+import okhttp3.OkHttpClient
+
+@OptIn(UnstableApi::class)
+class ExoPlayerBuilder(
+ val okHttp: OkHttpClient,
+) {
+ fun build(context: Context) =
+ ExoPlayer
+ .Builder(context)
+ .apply {
+ setMediaSourceFactory(CustomMediaSourceFactory(okHttp))
+ }.build()
+ .apply {
+ addListener(AspectRatioCacher(MediaAspectRatioCache))
+ addListener(KeepVideosPlaying(this))
+ addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt
new file mode 100644
index 000000000..a6257067a
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool
+
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.ConcurrentLinkedQueue
+
+@OptIn(UnstableApi::class)
+class ExoPlayerPool(
+ val builder: ExoPlayerBuilder,
+) {
+ private val playerPool = ConcurrentLinkedQueue()
+ private val poolSize = SimultaneousPlaybackCalculator.max()
+ private val poolStartingSize = 3
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ private val mutex = Mutex()
+
+ fun create(context: Context) {
+ while (playerPool.size < poolStartingSize) {
+ playerPool.offer(builder.build(context))
+ }
+ }
+
+ fun acquirePlayer(context: Context): ExoPlayer {
+ if (playerPool.isEmpty()) {
+ // If the pool is empty, create a new player (or handle it differently)
+ return builder.build(context)
+ }
+
+ return playerPool.poll() ?: builder.build(context)
+ }
+
+ fun releasePlayerAsync(player: ExoPlayer) {
+ scope.launch {
+ releasePlayer(player)
+ }
+ }
+
+ suspend fun releasePlayer(player: ExoPlayer) {
+ mutex.withLock {
+ player.pause()
+ player.stop()
+ player.clearMediaItems()
+ if (playerPool.size < poolSize) {
+ if (!playerPool.contains(player)) {
+ playerPool.add(player)
+ }
+ } else {
+ player.release() // Release if pool is full.
+ }
+ }
+ }
+
+ fun destroy() {
+ scope.launch {
+ mutex.withLock {
+ playerPool.forEach { it.release() }
+ playerPool.clear()
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt
new file mode 100644
index 000000000..8c3336473
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt
@@ -0,0 +1,208 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool
+
+import android.content.Context
+import android.util.Log
+import android.util.LruCache
+import androidx.annotation.OptIn
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
+import com.google.common.util.concurrent.Futures
+import com.google.common.util.concurrent.ListenableFuture
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.quartz.utils.TimeUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+class SessionListener(
+ val session: MediaSession,
+ val playerListener: Player.Listener,
+) {
+ fun removeListeners() {
+ session.player.removeListener(playerListener)
+ }
+}
+
+/**
+ * The goal for this class is to make sure all sessions and exoplayers are closed correctly.
+ */
+class MediaSessionPool(
+ val exoPlayerPool: ExoPlayerPool,
+ val reset: (MediaSession) -> Unit,
+) {
+ val globalCallback = MediaSessionCallback(this)
+ var lastCleanup = TimeUtils.now()
+
+ // protects from LruCache killing playing sessions
+ private val playingMap = mutableMapOf()
+
+ private val cache =
+ object : LruCache(SimultaneousPlaybackCalculator.max()) { // up to 10 videos in the screen at the same time
+ override fun entryRemoved(
+ evicted: Boolean,
+ key: String?,
+ oldValue: SessionListener?,
+ newValue: SessionListener?,
+ ) {
+ super.entryRemoved(evicted, key, oldValue, newValue)
+
+ if (!playingMap.contains(key)) {
+ oldValue?.let { pair ->
+ pair.removeListeners()
+ exoPlayerPool.releasePlayerAsync(pair.session.player as ExoPlayer)
+ pair.session.release()
+ }
+ }
+ }
+ }
+
+ fun newSession(
+ id: String,
+ context: Context,
+ ): MediaSession {
+ val mediaSession =
+ MediaSession
+ .Builder(context, exoPlayerPool.acquirePlayer(context))
+ .apply {
+ setId(id)
+ setCallback(globalCallback)
+ }.build()
+
+ val listener = MediaSessionExoPlayerConnector(mediaSession, this)
+
+ mediaSession.player.addListener(listener)
+
+ reset(mediaSession)
+
+ cache.put(mediaSession.id, SessionListener(mediaSession, listener))
+
+ return mediaSession
+ }
+
+ fun releaseSession(session: MediaSession) {
+ val listener = playingMap.get(session.id) ?: cache.get(session.id)
+ if (listener != null) {
+ session.player.removeListener(listener.playerListener)
+ }
+
+ cache.remove(session.id)
+ playingMap.remove(session.id)
+ session.release()
+ exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer)
+
+ cleanupUnused()
+ }
+
+ fun cleanupUnused() {
+ if (lastCleanup < TimeUtils.oneMinuteAgo()) {
+ lastCleanup = TimeUtils.now()
+ GlobalScope.launch(Dispatchers.Main) {
+ var counter = 0
+ val snap = cache.snapshot()
+ // makes a copy and awaits 10 seconds in case a new token was just created
+ // but not connected yet.
+ // delay(10000)
+ snap.values.forEach {
+ Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}")
+ if (it.session.connectedControllers.isEmpty()) {
+ releaseSession(it.session)
+ counter++
+ }
+ }
+ lastCleanup = TimeUtils.now()
+ }
+ }
+ }
+
+ fun destroy() {
+ GlobalScope.launch(Dispatchers.Main) {
+ cache.evictAll()
+ playingMap.forEach {
+ it.value.removeListeners()
+ exoPlayerPool.releasePlayer(it.value.session.player as ExoPlayer)
+ it.value.session.release()
+ }
+ playingMap.clear()
+ }
+
+ exoPlayerPool.destroy()
+ }
+
+ fun getSession(
+ id: String,
+ context: Context,
+ ): MediaSession {
+ val existingSession = playingMap.get(id) ?: cache.get(id)
+ if (existingSession != null) {
+ return existingSession.session
+ }
+
+ return newSession(id, context)
+ }
+
+ fun playingContent() = playingMap.values
+
+ class MediaSessionCallback(
+ val pool: MediaSessionPool,
+ ) : MediaSession.Callback {
+ @OptIn(UnstableApi::class)
+ override fun onAddMediaItems(
+ mediaSession: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaItems: List,
+ ): ListenableFuture> {
+ mediaSession.player.setMediaItems(mediaItems)
+
+ // set up return call when clicking on the Notification bar
+ mediaItems.firstOrNull()?.mediaMetadata?.extras?.getString("callbackUri")?.let {
+ mediaSession.setSessionActivity(Amethyst.Companion.instance.createIntent(it))
+ }
+
+ return Futures.immediateFuture(mediaItems)
+ }
+
+ override fun onDisconnected(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ ) {
+ pool.releaseSession(session)
+ }
+ }
+
+ class MediaSessionExoPlayerConnector(
+ val mediaSession: MediaSession,
+ val pool: MediaSessionPool,
+ ) : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ if (isPlaying) {
+ pool.playingMap.put(mediaSession.id, SessionListener(mediaSession, this))
+ } else {
+ pool.cache.put(mediaSession.id, SessionListener(mediaSession, this))
+ pool.playingMap.remove(mediaSession.id)
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt
new file mode 100644
index 000000000..cdd74c5a3
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/SimultaneousPlaybackCalculator.kt
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool
+
+import android.app.ActivityManager
+import android.content.Context
+import androidx.annotation.OptIn
+import androidx.core.content.getSystemService
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
+import com.vitorpamplona.amethyst.Amethyst
+
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+class SimultaneousPlaybackCalculator {
+ companion object {
+ fun isLowMemory(context: Context): Boolean {
+ val activityManager: ActivityManager? = context.getSystemService()
+ return activityManager?.isLowRamDevice == true
+ }
+
+ @OptIn(UnstableApi::class)
+ fun max(): Int {
+ val maxInstances =
+ try {
+ val info = MediaCodecUtil.getDecoderInfo(MimeTypes.VIDEO_H264, false, false)
+ if (info != null && info.maxSupportedInstances > 0) {
+ info.maxSupportedInstances
+ } else {
+ 0
+ }
+ } catch (_: MediaCodecUtil.DecoderQueryException) {
+ 0
+ }
+
+ if (maxInstances > 0) {
+ return maxInstances
+ }
+
+ return if (isLowMemory(Amethyst.instance)) {
+ 5
+ } else {
+ 10
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt
new file mode 100644
index 000000000..eec0355c8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/aspectRatio/AspectRatioCacher.kt
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.VideoSize
+import com.vitorpamplona.amethyst.model.MutableMediaAspectRatioCache
+
+class AspectRatioCacher(
+ val cache: MutableMediaAspectRatioCache,
+) : Player.Listener {
+ var currentUrl: String? = null
+
+ override fun onMediaItemTransition(
+ mediaItem: MediaItem?,
+ reason: Int,
+ ) {
+ if (mediaItem == null) {
+ currentUrl = null
+ } else {
+ currentUrl = mediaItem.mediaId
+ }
+ }
+
+ override fun onVideoSizeChanged(videoSize: VideoSize) {
+ currentUrl?.let {
+ cache.add(it, videoSize.width, videoSize.height)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt
new file mode 100644
index 000000000..55ee84b17
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/CurrentPlayPositionCacher.kt
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool.positions
+
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
+import kotlin.math.abs
+
+class CurrentPlayPositionCacher(
+ val player: Player,
+ val cache: MutableVideoViewedPositionCache,
+) : Player.Listener {
+ var currentUrl: String? = null
+ var isLiveStreaming: Boolean = false
+
+ override fun onMediaItemTransition(
+ mediaItem: MediaItem?,
+ reason: Int,
+ ) {
+ if (mediaItem == null) {
+ currentUrl = null
+ isLiveStreaming = false
+ } else {
+ currentUrl = mediaItem.mediaId
+ isLiveStreaming = isLiveStreaming(mediaItem.mediaId)
+ }
+ }
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ currentUrl?.let { uri ->
+ if (!isLiveStreaming) {
+ cache.add(uri, player.currentPosition)
+ }
+ }
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ currentUrl?.let { uri ->
+ when (playbackState) {
+ Player.STATE_IDLE -> {
+ // only saves if it wqs playing
+ if (!isLiveStreaming && abs(player.currentPosition) > 1) {
+ cache.add(uri, player.currentPosition)
+ }
+ }
+
+ Player.STATE_READY -> {
+ if (!isLiveStreaming) {
+ cache.get(uri)?.let { lastPosition ->
+ if (abs(player.currentPosition - lastPosition) > 5 * 60) {
+ player.seekTo(lastPosition)
+ }
+ }
+ }
+ }
+
+ else -> {
+ // only saves if it wqs playing
+ if (!isLiveStreaming && abs(player.currentPosition) > 1) {
+ cache.add(uri, player.currentPosition)
+ }
+ }
+ }
+ }
+ }
+
+ override fun onPositionDiscontinuity(
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int,
+ ) {
+ currentUrl?.let { uri ->
+ if (!isLiveStreaming && player.playbackState != Player.STATE_IDLE) {
+ cache.add(uri, newPosition.positionMs)
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt
new file mode 100644
index 000000000..e4add4497
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/positions/VideoViewedPositionCache.kt
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool.positions
+
+import android.util.LruCache
+
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+interface MutableVideoViewedPositionCache {
+ fun get(uri: String): Long?
+
+ fun add(
+ uri: String,
+ position: Long,
+ )
+}
+
+object VideoViewedPositionCache : MutableVideoViewedPositionCache {
+ val cachedPosition = LruCache(100)
+
+ override fun add(
+ uri: String,
+ position: Long,
+ ) {
+ cachedPosition.put(uri, position)
+ }
+
+ override fun get(uri: String): Long? = cachedPosition.get(uri)
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt
new file mode 100644
index 000000000..9c9f5d2e7
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/wake/KeepVideosPlaying.kt
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.playerPool.wake
+
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+
+class KeepVideosPlaying(
+ val player: ExoPlayer,
+) : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ if (isPlaying) {
+ player.setWakeMode(C.WAKE_MODE_NETWORK)
+ } else {
+ player.setWakeMode(C.WAKE_MODE_NONE)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt
new file mode 100644
index 000000000..907a2558f
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt
@@ -0,0 +1,135 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.service
+
+import android.util.Log
+import androidx.annotation.OptIn
+import androidx.media3.common.C
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
+import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
+import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
+import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
+import okhttp3.OkHttpClient
+
+class PlaybackService : MediaSessionService() {
+ private var poolNoProxy: MediaSessionPool? = null
+ private var poolWithProxy: MediaSessionPool? = null
+
+ @OptIn(UnstableApi::class)
+ fun newPool(okHttp: OkHttpClient): MediaSessionPool =
+ MediaSessionPool(
+ ExoPlayerPool(ExoPlayerBuilder(okHttp)),
+ reset = { session ->
+ (session.player as ExoPlayer).apply {
+ repeatMode = Player.REPEAT_MODE_ONE
+ videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
+ volume = 0f
+ }
+ },
+ )
+
+ @OptIn(UnstableApi::class)
+ fun lazyPool(proxyPort: Int): MediaSessionPool {
+ if (proxyPort <= 0) {
+ // no proxy
+ poolNoProxy?.let { return it }
+
+ // creates new
+ return newPool(HttpClientManager.getHttpClient(false)).also { poolNoProxy = it }
+ } else {
+ poolWithProxy?.let { pool ->
+ // with proxy, check if the port is the same.
+ val okHttp = HttpClientManager.getHttpClient(true)
+ if (okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
+ return pool
+ }
+
+ pool.destroy()
+ return newPool(okHttp).also { poolWithProxy = it }
+ }
+
+ // creates brand new
+ return newPool(HttpClientManager.getHttpClient(true)).also { poolWithProxy = it }
+ }
+ }
+
+ override fun onDestroy() {
+ Log.d("Lifetime Event", "PlaybackService.onDestroy")
+
+ poolWithProxy?.destroy()
+ poolNoProxy?.destroy()
+ super.onDestroy()
+ }
+
+ override fun onUpdateNotification(
+ session: MediaSession,
+ startInForegroundRequired: Boolean,
+ ) {
+ // Updates any new player ready
+ super.onUpdateNotification(session, startInForegroundRequired)
+
+ val proxyPlaying = poolWithProxy?.playingContent()
+
+ // Overrides the notification with any player actually playing
+ proxyPlaying?.forEach {
+ if (it.session.player.isPlaying) {
+ super.onUpdateNotification(it.session, startInForegroundRequired)
+ }
+ }
+
+ // Overrides again with playing with audio
+ proxyPlaying?.forEach {
+ if (it.session.player.isPlaying && it.session.player.volume > 0) {
+ super.onUpdateNotification(it.session, startInForegroundRequired)
+ }
+ }
+
+ val noProxyPlaying = poolNoProxy?.playingContent()
+
+ // Overrides the notification with any player actually playing
+ noProxyPlaying?.forEach {
+ if (it.session.player.isPlaying) {
+ super.onUpdateNotification(it.session, startInForegroundRequired)
+ }
+ }
+
+ // Overrides again with playing with audio
+ noProxyPlaying?.forEach {
+ if (it.session.player.isPlaying && it.session.player.volume > 0) {
+ super.onUpdateNotification(it.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 proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
+ val manager = lazyPool(proxyPort)
+ return manager.getSession(id, applicationContext)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt
new file mode 100644
index 000000000..d6a3ffecd
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt
@@ -0,0 +1,111 @@
+/**
+ * Copyright (c) 2024 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.playback.service
+
+import android.content.ComponentName
+import android.content.Context
+import android.os.Bundle
+import android.util.Log
+import androidx.media3.session.MediaController
+import androidx.media3.session.SessionToken
+import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+object PlaybackServiceClient {
+ val executorService: ExecutorService = Executors.newCachedThreadPool()
+
+ fun removeController(mediaControllerState: MediaControllerState) {
+ mediaControllerState.active.value = false
+ mediaControllerState.readyToDisplay.value = false
+
+ val myController = mediaControllerState.controller.value
+ // release when can
+ if (myController != null) {
+ mediaControllerState.controller.value = null
+ GlobalScope.launch(Dispatchers.Main) {
+ // myController.pause()
+ // myController.stop()
+ myController.release()
+ Log.d("PlaybackService", "Releasing Video $mediaControllerState")
+ }
+ }
+ }
+
+ fun prepareController(
+ mediaControllerState: MediaControllerState,
+ videoUri: String,
+ proxyPort: Int? = 0,
+ context: Context,
+ onReady: (MediaControllerState) -> Unit,
+ ) {
+ mediaControllerState.active.value = true
+
+ try {
+ val bundle =
+ Bundle().apply {
+ // link the id with the client's id to make sure it can return the
+ // same session on background media.
+ putString("id", mediaControllerState.id)
+ proxyPort?.let {
+ putInt("proxyPort", it)
+ }
+ }
+
+ val session = SessionToken(context, ComponentName(context, PlaybackService::class.java))
+
+ val controllerFuture =
+ MediaController
+ .Builder(context, session)
+ .setConnectionHints(bundle)
+ .buildAsync()
+
+ Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri")
+
+ controllerFuture.addListener(
+ {
+ try {
+ val controller = controllerFuture.get()
+ mediaControllerState.controller.value = controller
+
+ // checks if the player is still active before engaging further
+ if (mediaControllerState.isActive()) {
+ onReady(mediaControllerState)
+ } else {
+ removeController(mediaControllerState)
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
+ }
+ },
+ executorService,
+ )
+ } catch (e: Exception) {
+ if (e is CancellationException) throw e
+ Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssDataStreamCollector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt
similarity index 96%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssDataStreamCollector.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt
index 49fae2804..826e2f395 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssDataStreamCollector.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssDataStreamCollector.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.service.playback
+package com.vitorpamplona.amethyst.service.playback.websocket
import okhttp3.WebSocket
import okhttp3.WebSocketListener
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssStreamDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt
similarity index 98%
rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssStreamDataSource.kt
rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt
index 9e9be49a0..4f2b15089 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/WssStreamDataSource.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/websocket/WssStreamDataSource.kt
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
-package com.vitorpamplona.amethyst.service.playback
+package com.vitorpamplona.amethyst.service.playback.websocket
import android.net.Uri
import androidx.annotation.OptIn
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
index 4dd3b8301..e4bacdca9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt
@@ -25,6 +25,7 @@ import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
+import androidx.media3.common.MimeTypes
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
@@ -181,7 +182,7 @@ class MediaCompressor {
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
}
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
- MediaCompressorResult(compressedImageFile.toUri(), contentType, compressedImageFile.length())
+ MediaCompressorResult(compressedImageFile.toUri(), MimeTypes.IMAGE_JPEG, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
index 8f363caa3..e34dc096c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
-import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
-import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
+import com.vitorpamplona.amethyst.service.playback.composable.BackgroundMedia
+import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -185,11 +185,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
Log.d("Lifetime Event", "MainActivity.onDestroy")
- GlobalScope.launch(Dispatchers.Main) {
- keepPlayingMutex?.stop()
- keepPlayingMutex?.release()
- keepPlayingMutex = null
- }
+ BackgroundMedia.removeBackgroundControllerAndReleaseIt()
super.onDestroy()
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt
index 400c1bb40..debf88f2f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt
@@ -86,12 +86,12 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
-import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt
index c005df88c..5e7583c6f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt
@@ -55,10 +55,10 @@ object MediaSaverToDisk {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
- if (videoUri != null) {
- if (!videoUri.startsWith("file")) {
+ videoUri?.let { theVideoUri ->
+ if (!theVideoUri.startsWith("file")) {
downloadAndSave(
- url = videoUri,
+ url = theVideoUri,
mimeType = mimeType,
context = localContext,
forceProxy = forceProxy,
@@ -67,7 +67,7 @@ object MediaSaverToDisk {
)
} else {
save(
- localFile = videoUri.toUri().toFile(),
+ localFile = theVideoUri.toUri().toFile(),
mimeType = mimeType,
context = localContext,
onSuccess = onSuccess,
@@ -124,21 +124,21 @@ object MediaSaverToDisk {
checkNotNull(contentType) { "Can't find out the content type" }
val realType =
- if (mimeType != null && contentType == "application/octet-stream") {
- mimeType
+ if (contentType == "application/octet-stream") {
+ mimeType ?: getMimeTypeFromExtension(url)
} else {
contentType
}
saveContentQ(
- displayName = File(url).nameWithoutExtension,
+ displayName = File(trimInlineMetaData(url)).nameWithoutExtension,
contentType = realType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
- fileName = File(url).name,
+ fileName = File(trimInlineMetaData(url)).name,
contentSource = response.body.source(),
context = context,
)
@@ -154,6 +154,11 @@ object MediaSaverToDisk {
)
}
+ private fun getMimeTypeFromExtension(fileName: String): String =
+ fileName.substringAfterLast('.', "").lowercase().let {
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(it).orEmpty()
+ }
+
fun save(
localFile: File,
mimeType: String?,
@@ -175,7 +180,7 @@ object MediaSaverToDisk {
)
} else {
saveContentDefault(
- fileName = UUID.randomUUID().toString() + ".$extension",
+ fileName = "${UUID.randomUUID()}.$extension",
contentSource = buffer,
context = context,
)
@@ -206,10 +211,9 @@ object MediaSaverToDisk {
}
val masterUri =
- if (contentType.startsWith("image")) {
- MediaStore.Images.Media.EXTERNAL_CONTENT_URI
- } else {
- MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ when {
+ contentType.startsWith("image") -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ else -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri = contentResolver.insert(masterUri, contentValues)
@@ -251,5 +255,7 @@ object MediaSaverToDisk {
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
+ private fun trimInlineMetaData(url: String): String = url.substringBefore("#")
+
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt
index 66fcc1f95..fdb3f77bb 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/ShowImageUploadItem.kt
@@ -59,11 +59,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
-import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
deleted file mode 100644
index 3f127dba5..000000000
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt
+++ /dev/null
@@ -1,1244 +0,0 @@
-/**
- * Copyright (c) 2024 Vitor Pamplona
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of
- * this software and associated documentation files (the "Software"), to deal in
- * the Software without restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
- * Software, and to permit persons to whom the Software is furnished to do so,
- * subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
- * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
- * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
- * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-package com.vitorpamplona.amethyst.ui.components
-
-import android.Manifest
-import android.content.Context
-import android.graphics.Rect
-import android.graphics.drawable.Drawable
-import android.net.Uri
-import android.os.Build
-import android.util.Log
-import android.view.View
-import android.widget.Toast
-import androidx.annotation.OptIn
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.tween
-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.Row
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Share
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableFloatState
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.Stable
-import androidx.compose.runtime.State
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.composed
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.layout.LayoutCoordinates
-import androidx.compose.ui.layout.boundsInWindow
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalLifecycleOwner
-import androidx.compose.ui.platform.LocalView
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaMetadata
-import androidx.media3.common.Player
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.session.MediaController
-import androidx.media3.ui.AspectRatioFrameLayout
-import androidx.media3.ui.PlayerView
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.isGranted
-import com.google.accompanist.permissions.rememberPermissionState
-import com.linc.audiowaveform.infiniteLinearGradient
-import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
-import com.vitorpamplona.amethyst.commons.compose.produceCachedState
-import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
-import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
-import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
-import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
-import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
-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.stringRes
-import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
-import com.vitorpamplona.amethyst.ui.theme.Size110dp
-import com.vitorpamplona.amethyst.ui.theme.Size165dp
-import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
-import com.vitorpamplona.amethyst.ui.theme.Size55dp
-import com.vitorpamplona.amethyst.ui.theme.Size75dp
-import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
-import com.vitorpamplona.amethyst.ui.theme.imageModifier
-import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
-import com.vitorpamplona.quartz.experimental.audio.header.tags.WaveformTag
-import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.launch
-import java.util.UUID
-import java.util.concurrent.atomic.AtomicBoolean
-import kotlin.math.abs
-
-public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
-
-@Composable
-fun LoadThumbAndThenVideoView(
- videoUri: String,
- mimeType: String?,
- title: String? = null,
- thumbUri: String,
- authorName: String? = null,
- roundedCorner: Boolean,
- contentScale: ContentScale,
- nostrUriCallback: String? = null,
- accountViewModel: AccountViewModel,
- onDialog: ((Boolean) -> Unit)? = null,
-) {
- var loadingFinished by remember { mutableStateOf>(Pair(false, null)) }
- val context = LocalContext.current
-
- LaunchedEffect(Unit) {
- accountViewModel.loadThumb(
- context,
- thumbUri,
- onReady = {
- loadingFinished =
- if (it != null) {
- Pair(true, it)
- } else {
- Pair(true, null)
- }
- },
- onError = { loadingFinished = Pair(true, null) },
- )
- }
-
- if (loadingFinished.first) {
- if (loadingFinished.second != null) {
- VideoView(
- videoUri = videoUri,
- mimeType = mimeType,
- title = title,
- thumb = VideoThumb(loadingFinished.second),
- roundedCorner = roundedCorner,
- contentScale = contentScale,
- artworkUri = thumbUri,
- authorName = authorName,
- nostrUriCallback = nostrUriCallback,
- accountViewModel = accountViewModel,
- onDialog = onDialog,
- )
- } else {
- VideoView(
- videoUri = videoUri,
- mimeType = mimeType,
- title = title,
- thumb = null,
- roundedCorner = roundedCorner,
- contentScale = contentScale,
- artworkUri = thumbUri,
- authorName = authorName,
- nostrUriCallback = nostrUriCallback,
- accountViewModel = accountViewModel,
- onDialog = onDialog,
- )
- }
- }
-}
-
-@Composable
-fun VideoView(
- videoUri: String,
- mimeType: String?,
- title: String? = null,
- thumb: VideoThumb? = null,
- roundedCorner: Boolean,
- gallery: Boolean = false,
- contentScale: ContentScale,
- waveform: WaveformTag? = null,
- artworkUri: String? = null,
- authorName: String? = null,
- dimensions: DimensionTag? = null,
- blurhash: String? = null,
- nostrUriCallback: String? = null,
- onDialog: ((Boolean) -> Unit)? = null,
- onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
- accountViewModel: AccountViewModel,
- alwaysShowVideo: Boolean = false,
-) {
- val borderModifier =
- if (roundedCorner) {
- MaterialTheme.colorScheme.imageModifier
- } else if (gallery) {
- MaterialTheme.colorScheme.videoGalleryModifier
- } else {
- Modifier
- }
-
- VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo)
-}
-
-@Composable
-fun VideoView(
- videoUri: String,
- mimeType: String?,
- title: String? = null,
- thumb: VideoThumb? = null,
- borderModifier: Modifier,
- contentScale: ContentScale,
- waveform: WaveformTag? = null,
- artworkUri: String? = null,
- authorName: String? = null,
- dimensions: DimensionTag? = null,
- blurhash: String? = null,
- nostrUriCallback: String? = null,
- onDialog: ((Boolean) -> Unit)? = null,
- onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
- accountViewModel: AccountViewModel,
- alwaysShowVideo: Boolean = false,
- showControls: Boolean = true,
-) {
- val defaultToStart by remember(videoUri) { mutableStateOf(DEFAULT_MUTED_SETTING.value) }
-
- val automaticallyStartPlayback =
- remember {
- mutableStateOf(
- if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value,
- )
- }
-
- if (blurhash == null) {
- val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
-
- val modifier =
- if (ratio != null && automaticallyStartPlayback.value) {
- Modifier.aspectRatio(ratio)
- } else {
- Modifier
- }
-
- Box(modifier) {
- if (!automaticallyStartPlayback.value) {
- Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
- }
- } else {
- VideoViewInner(
- videoUri = videoUri,
- mimeType = mimeType,
- defaultToStart = defaultToStart,
- title = title,
- thumb = thumb,
- borderModifier = borderModifier,
- contentScale = contentScale,
- waveform = waveform,
- artworkUri = artworkUri,
- authorName = authorName,
- nostrUriCallback = nostrUriCallback,
- automaticallyStartPlayback = automaticallyStartPlayback,
- onControllerVisibilityChanged = onControllerVisibilityChanged,
- onDialog = onDialog,
- accountViewModel = accountViewModel,
- )
- }
- }
- } else {
- val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
-
- val modifier =
- if (ratio != null) {
- Modifier.aspectRatio(ratio)
- } else {
- Modifier
- }
-
- Box(modifier, contentAlignment = Alignment.Center) {
- // Always displays Blurharh to avoid size flickering
- DisplayBlurHash(
- blurhash,
- null,
- contentScale,
- if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier,
- )
-
- if (!automaticallyStartPlayback.value) {
- IconButton(
- modifier = Modifier.size(Size75dp),
- onClick = { automaticallyStartPlayback.value = true },
- ) {
- DownloadForOfflineIcon(Size75dp, Color.White)
- }
- } else {
- VideoViewInner(
- videoUri = videoUri,
- mimeType = mimeType,
- defaultToStart = defaultToStart,
- title = title,
- thumb = thumb,
- borderModifier = borderModifier,
- contentScale = contentScale,
- waveform = waveform,
- artworkUri = artworkUri,
- authorName = authorName,
- nostrUriCallback = nostrUriCallback,
- automaticallyStartPlayback = automaticallyStartPlayback,
- onControllerVisibilityChanged = onControllerVisibilityChanged,
- onDialog = onDialog,
- accountViewModel = accountViewModel,
- showControls = showControls,
- )
- }
- }
- }
-}
-
-@Composable
-@OptIn(androidx.media3.common.util.UnstableApi::class)
-fun VideoViewInner(
- videoUri: String,
- mimeType: String?,
- defaultToStart: Boolean = false,
- title: String? = null,
- thumb: VideoThumb? = null,
- showControls: Boolean = true,
- contentScale: ContentScale,
- borderModifier: Modifier,
- waveform: WaveformTag? = null,
- artworkUri: String? = null,
- authorName: String? = null,
- nostrUriCallback: String? = null,
- automaticallyStartPlayback: State,
- onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
- onDialog: ((Boolean) -> Unit)? = null,
- accountViewModel: AccountViewModel,
-) {
- GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
- GetVideoController(
- mediaItem = mediaItem,
- videoUri = videoUri,
- defaultToStart = defaultToStart,
- nostrUriCallback = nostrUriCallback,
- proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(videoUri)),
- ) { controller, keepPlaying ->
- VideoPlayerActiveMutex(controller) { videoModifier, activeOnScreen ->
- RenderVideoPlayer(
- videoUri = videoUri,
- mimeType = mimeType,
- controller = controller,
- thumbData = thumb,
- showControls = showControls,
- contentScale = contentScale,
- nostrUriCallback = nostrUriCallback,
- waveform = waveform,
- keepPlaying = keepPlaying,
- automaticallyStartPlayback = automaticallyStartPlayback,
- activeOnScreen = activeOnScreen,
- borderModifier = borderModifier,
- videoModifier = videoModifier,
- onControllerVisibilityChanged = onControllerVisibilityChanged,
- onDialog = onDialog,
- accountViewModel = accountViewModel,
- )
- }
- }
- }
-}
-
-val mediaItemCache = MediaItemCache()
-
-@Immutable
-data class MediaItemData(
- val videoUri: String,
- val authorName: String? = null,
- val title: String? = null,
- val artworkUri: String? = null,
-)
-
-class MediaItemCache : GenericBaseCache(20) {
- override suspend fun compute(key: MediaItemData): MediaItem =
- MediaItem
- .Builder()
- .setMediaId(key.videoUri)
- .setUri(key.videoUri)
- .setMediaMetadata(
- MediaMetadata
- .Builder()
- .setArtist(key.authorName?.ifBlank { null })
- .setTitle(key.title?.ifBlank { null } ?: key.videoUri)
- .setArtworkUri(
- try {
- if (key.artworkUri != null) {
- Uri.parse(key.artworkUri)
- } else {
- null
- }
- } catch (e: Exception) {
- if (e is CancellationException) throw e
- null
- },
- ).build(),
- ).build()
-}
-
-@Composable
-fun GetMediaItem(
- videoUri: String,
- title: String?,
- artworkUri: String?,
- authorName: String?,
- inner: @Composable (State) -> Unit,
-) {
- val data =
- remember(videoUri) {
- MediaItemData(
- videoUri = videoUri,
- authorName = authorName,
- title = title,
- artworkUri = artworkUri,
- )
- }
- val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
-
- mediaItem?.let {
- val myState = remember(videoUri) { mutableStateOf(it) }
- inner(myState)
- }
-}
-
-@Composable
-@OptIn(UnstableApi::class)
-fun GetVideoController(
- mediaItem: State,
- videoUri: String,
- proxyPort: Int?,
- defaultToStart: Boolean = false,
- nostrUriCallback: String? = null,
- inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> Unit,
-) {
- val context = LocalContext.current
-
- val onlyOnePreparing = AtomicBoolean()
-
- val controller =
- remember(videoUri) {
- mutableStateOf(
- if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) {
- keepPlayingMutex
- } else {
- null
- },
- )
- }
-
- val keepPlaying =
- remember(videoUri) {
- mutableStateOf(
- keepPlayingMutex != null && controller.value == keepPlayingMutex,
- )
- }
-
- val uid = remember(videoUri) { UUID.randomUUID().toString() }
-
- val scope = rememberCoroutineScope()
-
- // Prepares a VideoPlayer from the foreground service.
- DisposableEffect(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) {
- // If there is a connection, don't wait.
- if (!onlyOnePreparing.getAndSet(true)) {
- scope.launch(Dispatchers.IO) {
- Log.d("PlaybackService", "Preparing Video $videoUri ")
- PlaybackClientController.prepareController(
- uid,
- videoUri,
- nostrUriCallback,
- proxyPort,
- context,
- ) {
- scope.launch(Dispatchers.Main) {
- // REQUIRED TO BE RUN IN THE MAIN THREAD
- if (!it.isPlaying) {
- if (keepPlayingMutex?.isPlaying == true) {
- // 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.value)
- it.prepare()
-
- controller.value = it
-
- onlyOnePreparing.getAndSet(false)
- }
- }
- }
- }
- } else {
- // has been loaded. prepare to play
- controller.value?.let {
- scope.launch(Dispatchers.Main) {
- if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
- Log.d("PlaybackService", "Preparing Existing Video $videoUri ")
-
- 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
- }
-
- if (mediaItem.value != it.currentMediaItem) {
- it.setMediaItem(mediaItem.value)
- }
-
- it.prepare()
- }
- }
- }
- }
-
- onDispose {
- if (!keepPlaying.value) {
- // Makes sure the variable is cleared before the task is launched
- // to avoid the ON_RELEASE running before ON_PAUSE's coroutine
- val toRelease = controller.value
- controller.value = null
-
- toRelease?.let {
- it.pause()
-
- GlobalScope.launch(Dispatchers.Main) {
- // Stops and releases the media.
- it.stop()
- it.release()
- Log.d("PlaybackService", "Releasing Video $videoUri ")
- }
- }
- }
- }
- }
-
- // User pauses and resumes the app. What to do with videos?
- val lifeCycleOwner = LocalLifecycleOwner.current
- DisposableEffect(key1 = lifeCycleOwner) {
- 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) {
- if (!onlyOnePreparing.getAndSet(true)) {
- scope.launch(Dispatchers.IO) {
- Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
- PlaybackClientController.prepareController(
- uid,
- videoUri,
- nostrUriCallback,
- proxyPort,
- context,
- ) {
- scope.launch(Dispatchers.Main) {
- // REQUIRED TO BE RUN IN THE MAIN THREAD
- // checks again to make sure no other thread has created a controller.
- if (!it.isPlaying) {
- if (keepPlayingMutex?.isPlaying == true) {
- // 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.value)
- it.prepare()
-
- controller.value = it
- onlyOnePreparing.getAndSet(false)
- }
- }
- }
- }
- }
- }
- if (event == Lifecycle.Event.ON_PAUSE) {
- if (!keepPlaying.value) {
- // Stops and releases the media.
- // Makes sure the variable is cleared before the task is launched
- // to avoid the ON_RELEASE running before ON_PAUSE's coroutine
- val toRelease = controller.value
- controller.value = null
-
- toRelease?.let {
- it.pause()
-
- scope.launch(Dispatchers.Main) {
- Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
- it.stop()
- it.release()
- Log.d("PlaybackService", "Released Video from Pause $videoUri ")
- }
- }
- }
- }
- }
-
- lifeCycleOwner.lifecycle.addObserver(observer)
- onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
- }
-
- controller.value?.let {
- inner(it, keepPlaying)
- }
-}
-
-// background playing mutex.
-var keepPlayingMutex: MediaController? = null
-
-// This keeps the position of all visible videos in the current screen.
-val trackingVideos = mutableListOf()
-
-@Stable
-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 VideoPlayerActiveMutex(
- controller: MediaController,
- inner: @Composable (Modifier, MutableState) -> Unit,
-) {
- val myCache = remember(controller) { VisibilityData() }
-
- // Is the current video the closest to the center?
- val active = remember(controller) { mutableStateOf(false) }
-
- // Keep track of all available videos.
- DisposableEffect(key1 = controller) {
- trackingVideos.add(myCache)
- onDispose { trackingVideos.remove(myCache) }
- }
-
- val videoModifier =
- remember(controller) {
- Modifier.fillMaxWidth().heightIn(min = 100.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(videoModifier, active)
-}
-
-@Stable
-data class VideoThumb(
- val thumb: Drawable?,
-)
-
-@Composable
-@OptIn(UnstableApi::class)
-private fun RenderVideoPlayer(
- videoUri: String,
- mimeType: String?,
- controller: MediaController,
- thumbData: VideoThumb?,
- showControls: Boolean = true,
- contentScale: ContentScale,
- nostrUriCallback: String?,
- waveform: WaveformTag? = null,
- keepPlaying: MutableState,
- automaticallyStartPlayback: State,
- activeOnScreen: MutableState,
- borderModifier: Modifier,
- videoModifier: Modifier,
- onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
- onDialog: ((Boolean) -> Unit)?,
- accountViewModel: AccountViewModel,
-) {
- ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
-
- val controllerVisible = remember(controller) { mutableStateOf(false) }
-
- Box(modifier = borderModifier) {
- AndroidView(
- modifier = videoModifier,
- factory = { context: Context ->
- PlayerView(context).apply {
- player = controller
- setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
- setBackgroundColor(Color.Transparent.toArgb())
- setShutterBackgroundColor(Color.Transparent.toArgb())
-
- controllerAutoShow = false
- useController = showControls
- thumbData?.thumb?.let { defaultArtwork = it }
- hideController()
-
- resizeMode =
- when (contentScale) {
- ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT
- ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
- ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL
- ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
- ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
- else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
- }
-
- if (showControls) {
- onDialog?.let { innerOnDialog ->
- setFullscreenButtonClickListener {
- controller.pause()
- innerOnDialog(it)
- }
- }
- setControllerVisibilityListener(
- PlayerView.ControllerVisibilityListener { visible ->
- controllerVisible.value = visible == View.VISIBLE
- onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) }
- },
- )
- }
- }
- },
- )
-
- waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
-
- if (showControls) {
- val startingMuteState = remember(controller) { controller.volume < 0.001 }
-
- MuteButton(
- controllerVisible,
- startingMuteState,
- Modifier.align(Alignment.TopEnd),
- ) { mute: Boolean ->
- // makes the new setting the default for new creations.
- DEFAULT_MUTED_SETTING.value = mute
-
- // 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?.release()
- keepPlayingMutex = null
- }
-
- controller.volume = if (mute) 0f else 1f
- }
-
- KeepPlayingButton(
- keepPlaying,
- controllerVisible,
- Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
- ) { 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?.release()
- }
- keepPlayingMutex = controller
- } else {
- if (keepPlayingMutex == controller) {
- keepPlayingMutex = null
- }
- }
-
- keepPlaying.value = newKeepPlaying
- }
-
- if (!videoUri.endsWith(".m3u8")) {
- AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
- saveMediaToGallery(videoUri, mimeType, context, accountViewModel)
- }
-
- AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
- ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
- }
- } else {
- AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { popupExpanded, toggle ->
- ShareImageAction(accountViewModel = accountViewModel, popupExpanded, videoUri, nostrUriCallback, null, null, null, mimeType, toggle)
- }
- }
- } else {
- controller.volume = 0f
- }
- }
-}
-
-private fun pollCurrentDuration(controller: MediaController) =
- flow {
- while (controller.currentPosition <= controller.duration) {
- emit(controller.currentPosition / controller.duration.toFloat())
- delay(100)
- }
- }.conflate()
-
-@Composable
-fun Waveform(
- waveform: WaveformTag,
- controller: MediaController,
- modifier: Modifier,
-) {
- val waveformProgress = remember { mutableFloatStateOf(0F) }
-
- DrawWaveform(waveform, waveformProgress, modifier)
-
- val restartFlow = remember { mutableIntStateOf(0) }
-
- // 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.
- if (isPlaying) {
- restartFlow.intValue += 1
- }
- }
- }
-
- controller.addListener(listener)
- onDispose { controller.removeListener(listener) }
- }
-
- LaunchedEffect(key1 = restartFlow.intValue) {
- pollCurrentDuration(controller).collect { value -> waveformProgress.floatValue = value }
- }
-}
-
-@Composable
-fun DrawWaveform(
- waveform: WaveformTag,
- waveformProgress: MutableFloatState,
- modifier: Modifier,
-) {
- AudioWaveformReadOnly(
- modifier = modifier.padding(start = 10.dp, end = 10.dp),
- amplitudes = waveform.wave,
- progress = waveformProgress.floatValue,
- progressBrush =
- Brush.infiniteLinearGradient(
- colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
- animation = tween(durationMillis = 6000, easing = LinearEasing),
- width = 128F,
- ),
- onProgressChange = { waveformProgress.floatValue = it },
- )
-}
-
-@Composable
-fun ControlWhenPlayerIsActive(
- controller: Player,
- keepPlaying: MutableState,
- automaticallyStartPlayback: State,
- activeOnScreen: MutableState,
-) {
- LaunchedEffect(key1 = activeOnScreen.value) {
- // 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, key2 = view) {
- 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.
- if (view.keepScreenOn != isPlaying) {
- view.keepScreenOn = isPlaying
- }
- }
- }
-
- controller.addListener(listener)
- onDispose {
- if (view.keepScreenOn) {
- view.keepScreenOn = false
- }
- controller.removeListener(listener)
- }
- }
-}
-
-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(
- controllerVisible: MutableState,
- startingMuteState: Boolean,
- modifier: Modifier,
- toggle: (Boolean) -> Unit,
-) {
- val holdOn =
- remember {
- mutableStateOf(
- true,
- )
- }
-
- LaunchedEffect(key1 = controllerVisible) {
- launch(Dispatchers.Default) {
- delay(2000)
- holdOn.value = false
- }
- }
-
- val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
-
- AnimatedVisibility(
- visible = holdOn.value || controllerVisible.value,
- modifier = modifier,
- enter = remember { fadeIn() },
- exit = remember { fadeOut() },
- ) {
- Box(modifier = VolumeBottomIconSize) {
- Box(
- Modifier
- .clip(CircleShape)
- .fillMaxSize(0.6f)
- .align(Alignment.Center)
- .background(MaterialTheme.colorScheme.background),
- )
-
- IconButton(
- onClick = {
- mutedInstance.value = !mutedInstance.value
- toggle(mutedInstance.value)
- },
- modifier = Size50Modifier,
- ) {
- if (mutedInstance.value) {
- MutedIcon()
- } else {
- MuteIcon()
- }
- }
- }
- }
-}
-
-@Composable
-private fun KeepPlayingButton(
- keepPlayingStart: MutableState,
- controllerVisible: MutableState,
- modifier: Modifier,
- toggle: (Boolean) -> Unit,
-) {
- val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
-
- AnimatedVisibility(
- visible = controllerVisible.value,
- modifier = modifier,
- enter = remember { fadeIn() },
- exit = remember { fadeOut() },
- ) {
- Box(modifier = PinBottomIconSize) {
- Box(
- Modifier
- .clip(CircleShape)
- .fillMaxSize(0.6f)
- .align(Alignment.Center)
- .background(MaterialTheme.colorScheme.background),
- )
-
- IconButton(
- onClick = {
- keepPlaying.value = !keepPlaying.value
- toggle(keepPlaying.value)
- },
- modifier = Size50Modifier,
- ) {
- if (keepPlaying.value) {
- LyricsIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
- } else {
- LyricsOffIcon(Size22Modifier, MaterialTheme.colorScheme.onBackground)
- }
- }
- }
- }
-}
-
-@Composable
-fun AnimatedSaveButton(
- controllerVisible: State,
- modifier: Modifier,
- onSaveClick: (localContext: Context) -> Unit,
-) {
- AnimatedVisibility(
- visible = controllerVisible.value,
- modifier = modifier,
- enter = remember { fadeIn() },
- exit = remember { fadeOut() },
- ) {
- SaveButton(onSaveClick)
- }
-}
-
-@Composable
-fun AnimatedShareButton(
- controllerVisible: State,
- modifier: Modifier,
- innerAction: @Composable (MutableState, () -> Unit) -> Unit,
-) {
- AnimatedVisibility(
- visible = controllerVisible.value,
- modifier = modifier,
- enter = remember { fadeIn() },
- exit = remember { fadeOut() },
- ) {
- ShareButton(innerAction)
- }
-}
-
-@Composable
-fun ShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) {
- Box(modifier = PinBottomIconSize) {
- Box(
- Modifier
- .clip(CircleShape)
- .fillMaxSize(0.6f)
- .align(Alignment.Center)
- .background(MaterialTheme.colorScheme.background),
- )
-
- val popupExpanded = remember { mutableStateOf(false) }
-
- IconButton(
- onClick = {
- popupExpanded.value = true
- },
- modifier = Size50Modifier,
- ) {
- Icon(
- imageVector = Icons.Default.Share,
- modifier = Size20Modifier,
- contentDescription = stringRes(R.string.share_or_save),
- )
-
- innerAction(popupExpanded) { popupExpanded.value = false }
- }
- }
-}
-
-@kotlin.OptIn(ExperimentalPermissionsApi::class)
-@Composable
-fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
- Box(modifier = PinBottomIconSize) {
- Box(
- Modifier
- .clip(CircleShape)
- .fillMaxSize(0.6f)
- .align(Alignment.Center)
- .background(MaterialTheme.colorScheme.background),
- )
-
- val localContext = LocalContext.current
-
- val writeStoragePermissionState =
- rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
- if (isGranted) {
- onSaveClick(localContext)
- }
- }
- val scope = rememberCoroutineScope()
- IconButton(
- onClick = {
- scope.launch {
- Toast
- .makeText(
- localContext,
- stringRes(localContext, R.string.video_download_has_started_toast),
- Toast.LENGTH_SHORT,
- ).show()
- }
- if (
- Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
- writeStoragePermissionState.status.isGranted
- ) {
- onSaveClick(localContext)
- } else {
- writeStoragePermissionState.launchPermissionRequest()
- }
- },
- modifier = Size50Modifier,
- ) {
- Icon(
- imageVector = Icons.Default.Download,
- modifier = Size20Modifier,
- contentDescription = stringRes(R.string.save_to_gallery),
- )
- }
- }
-}
-
-private fun saveMediaToGallery(
- videoUri: String?,
- mimeType: String?,
- localContext: Context,
- accountViewModel: AccountViewModel,
-) {
- MediaSaverToDisk.saveDownloadingIfNeeded(
- videoUri = videoUri,
- forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(),
- mimeType = mimeType,
- localContext = localContext,
- onSuccess = {
- accountViewModel.toastManager.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
- },
- onError = {
- accountViewModel.toastManager.toast(R.string.failed_to_save_the_video, null, it)
- },
- )
-}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt
index fa770082a..ff4b2c795 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt
@@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -76,6 +77,9 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
+import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
+import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
+import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -234,7 +238,7 @@ private fun DialogContent(
ShareImageAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false })
}
- if (myContent !is MediaUrlContent || !myContent.url.endsWith(".m3u8")) {
+ if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
@@ -424,18 +428,29 @@ private fun RenderImageOrVideo(
Modifier.fillMaxWidth()
}
- VideoViewInner(
- videoUri = content.url,
- mimeType = content.mimeType,
- title = content.description,
- artworkUri = content.artworkUri,
- authorName = content.authorName,
- borderModifier = borderModifier,
- contentScale = contentScale,
- automaticallyStartPlayback = automaticallyStartPlayback,
- onControllerVisibilityChanged = onControllerVisibilityChanged,
- accountViewModel = accountViewModel,
- )
+ val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
+
+ val modifier =
+ if (ratio != null) {
+ Modifier.aspectRatio(ratio)
+ } else {
+ Modifier
+ }
+
+ Box(modifier, contentAlignment = Alignment.Center) {
+ VideoViewInner(
+ videoUri = content.url,
+ mimeType = content.mimeType,
+ title = content.description,
+ artworkUri = content.artworkUri,
+ authorName = content.authorName,
+ borderModifier = borderModifier,
+ contentScale = contentScale,
+ automaticallyStartPlayback = automaticallyStartPlayback,
+ onControllerVisibilityChanged = onControllerVisibilityChanged,
+ accountViewModel = accountViewModel,
+ )
+ }
}
is MediaLocalImage -> {
@@ -471,18 +486,29 @@ private fun RenderImageOrVideo(
}
content.localFile?.let {
- VideoViewInner(
- videoUri = it.toUri().toString(),
- mimeType = content.mimeType,
- title = content.description,
- artworkUri = content.artworkUri,
- authorName = content.authorName,
- borderModifier = borderModifier,
- contentScale = contentScale,
- automaticallyStartPlayback = automaticallyStartPlayback,
- onControllerVisibilityChanged = onControllerVisibilityChanged,
- accountViewModel = accountViewModel,
- )
+ val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(it.toUri().toString())
+
+ val modifier =
+ if (ratio != null) {
+ Modifier.aspectRatio(ratio)
+ } else {
+ Modifier
+ }
+
+ Box(modifier, contentAlignment = Alignment.Center) {
+ VideoViewInner(
+ videoUri = it.toUri().toString(),
+ mimeType = content.mimeType,
+ title = content.description,
+ artworkUri = content.artworkUri,
+ authorName = content.authorName,
+ borderModifier = borderModifier,
+ contentScale = contentScale,
+ automaticallyStartPlayback = automaticallyStartPlayback,
+ onControllerVisibilityChanged = onControllerVisibilityChanged,
+ accountViewModel = accountViewModel,
+ )
+ }
}
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
index 3520fe829..eb216dc59 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
@@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.Blurhash
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
index 9bbd04720..7aaf2127a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Icons.kt
@@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
-import androidx.compose.material.icons.automirrored.filled.VolumeOff
-import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
@@ -415,26 +413,6 @@ fun CloseIcon() {
)
}
-@Composable
-fun MutedIcon() {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.VolumeOff,
- contentDescription = stringRes(id = R.string.muted_button),
- tint = MaterialTheme.colorScheme.onBackground,
- modifier = Size30Modifier,
- )
-}
-
-@Composable
-fun MuteIcon() {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.VolumeUp,
- contentDescription = stringRes(id = R.string.mute_button),
- tint = MaterialTheme.colorScheme.onBackground,
- modifier = Size30Modifier,
- )
-}
-
@Composable
fun SearchIcon(
modifier: Modifier,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt
index 3535caff2..8f24e4f49 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt
@@ -43,9 +43,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
-import com.vitorpamplona.amethyst.ui.components.LoadThumbAndThenVideoView
+import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
-import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt
index e53c9e29c..4d91eda84 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt
@@ -50,8 +50,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
-import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt
index 8a27ae796..2f6fcd176 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt
@@ -129,6 +129,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.LocationState
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
+import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewPollOption
import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange
@@ -149,7 +150,6 @@ import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SecretEmojiRequest
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
-import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.navigation.Nav
import com.vitorpamplona.amethyst.ui.navigation.getActivity
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt
index 8aee3d211..32ecb90e1 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt
@@ -52,12 +52,12 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.okhttp.HttpClientManager
+import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
+import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
-import com.vitorpamplona.amethyst.ui.components.GetMediaItem
-import com.vitorpamplona.amethyst.ui.components.GetVideoController
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@@ -284,20 +284,19 @@ fun UrlVideoView(
DownloadForOfflineIcon(Size75dp, Color.White)
}
} else {
- GetMediaItem(content.url, content.description, content.artworkUri, content.authorName) { mediaItem ->
+ GetMediaItem(content.url, content.description, content.artworkUri, content.authorName, content.uri) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
videoUri = content.url,
- defaultToStart = true,
- nostrUriCallback = content.uri,
+ muted = true,
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(content.url)),
- ) { controller, keepPlaying ->
+ ) { controller ->
AndroidView(
modifier = Modifier,
factory = { context: Context ->
PlayerView(context).apply {
clipToOutline = true
- player = controller
+ player = controller.controller.value
setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS)
controllerAutoShow = false
@@ -307,7 +306,7 @@ fun UrlVideoView(
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
- controller.playWhenReady = true
+ controller.controller.value?.playWhenReady = true
}
},
)
diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml
index e1b52ea07..7267d82bf 100644
--- a/amethyst/src/main/res/values-cs/strings.xml
+++ b/amethyst/src/main/res/values-cs/strings.xml
@@ -20,6 +20,7 @@
Zneužívání identityNezákonné jednáníJiný
+ ObtěžováníNeznámýIkona předáváníNeznámý autor
diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml
index 488f7996b..a24e5ee86 100644
--- a/amethyst/src/main/res/values-de/strings.xml
+++ b/amethyst/src/main/res/values-de/strings.xml
@@ -20,6 +20,7 @@
VortäuschungIllegales VerhaltenAndere
+ BelästigungUnbekanntRelay-SymbolUnbekannter Autor
diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml
index 7a45dad67..404809453 100644
--- a/amethyst/src/main/res/values-fr/strings.xml
+++ b/amethyst/src/main/res/values-fr/strings.xml
@@ -20,6 +20,7 @@
Falsification d\'identitéComportement illégalAutre
+ HarcèlementinconnuIcône de relaisAuteur inconnu
diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml
index 24b790b76..94e5e2a55 100644
--- a/amethyst/src/main/res/values-hi-rIN/strings.xml
+++ b/amethyst/src/main/res/values-hi-rIN/strings.xml
@@ -20,6 +20,7 @@
पररूपणअवैध बरतावअन्य
+ उत्पीडनअज्ञातपनःप्रसारक चिह्नअज्ञात लेखक
diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml
index f2a494754..265f04fef 100644
--- a/amethyst/src/main/res/values-pl-rPL/strings.xml
+++ b/amethyst/src/main/res/values-pl-rPL/strings.xml
@@ -20,6 +20,7 @@
Podszywanie sięAntyspołeczne zachowanieInne
+ NękanieNieznaniIkona transmiteraAutor nieznany
diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml
index a6afa7381..01f4d5b25 100644
--- a/amethyst/src/main/res/values-pt-rBR/strings.xml
+++ b/amethyst/src/main/res/values-pt-rBR/strings.xml
@@ -20,6 +20,7 @@
RepresentaçãoComportamento ilegalOutro
+ AssédioDesconhecidoÍcone do relayAutor desconhecido
diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml
index 495445480..faad5aef4 100644
--- a/amethyst/src/main/res/values-sv-rSE/strings.xml
+++ b/amethyst/src/main/res/values-sv-rSE/strings.xml
@@ -20,6 +20,7 @@
ImitationOlagligt beteendeAnnat
+ TrakasseriOkändRelä ikonOkänd användare
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 9e9dd9327..e65894dbf 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1,6 +1,7 @@
Amethyst
- Amethyst Debug
+ Amy Debug
+ Amy BenchmarkPoint to the QR CodeShow QRProfile Picture
diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt
index 1f543dd3d..aaa0a86e6 100644
--- a/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt
+++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip78AppData/AppSpecificDataEvent.kt
@@ -36,6 +36,8 @@ class AppSpecificDataEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
+ override fun isContentEncoded() = true
+
companion object {
const val KIND = 30078
const val ALT = "Arbitrary app data"