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..f09d515c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/BackgroundMedia.kt @@ -0,0 +1,75 @@ +/** + * 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 { + println("AABBCCDD removeBackgroundControllerAndReleaseIt") + removeController(it) + bgInstance.tryEmit(null) + } + } + + fun removeBackgroundControllerIfNotComposed() { + bgInstance.value?.let { + println("AABBCCDD removeBackgroundControllerIfNotComposed ${it.composed.value}") + 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..f41cce2b2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -0,0 +1,248 @@ +/** + * 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) { + println("AABBCC On DisposableEffect: ${controllerId.id} ${controllerId.controller.value}") + // 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 { + println("AABBCC On Dispose: ${controllerId.id} ${controllerId.controller.value}") + 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 + println("AABBCC On Resume: ${controllerId.id} ${controllerId.controller.value}") + // 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", "AABBCC 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 + println("AABBCC On Pause: ${controllerId.keepPlaying.value} ${controllerId.id}") + 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..8716728a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -0,0 +1,220 @@ +/** + * 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.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 { + println("AABBCC New Session. Cache has ${cache.size()} sessions. Playing ${playingMap.size}") + 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) { + println("AABBCC Release Session ${session.id}. Cache has ${cache.size()} sessions. Playing ${playingMap.size}") + val listener = playingMap.get(session.id) ?: cache.get(session.id) + if (listener != null) { + session.player.removeListener(listener.playerListener) + } else { + println("AABBCC ERROR listener not found") + } + + 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 { + println("AABBCC CleanUpUnused ${it.session.connectedControllers.size} ${it.session.id}") + it.session.connectedControllers.forEach { conn -> + println("AABBCC CleanUpUnused ${conn.connectionHints.keySet().joinToString(", ") { "$it " + conn.connectionHints.get(it).toString() }}") + } + if (it.session.connectedControllers.isEmpty()) { + releaseSession(it.session) + counter++ + } + } + lastCleanup = TimeUtils.now() + + println("AABBCC Launched Cleanup: $counter sessions released") + } + } + } + + 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) { + println("AABBCC Reusing session $id") + 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> { + println("AABBCC onAddMediaItems ${mediaSession.id}") + 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, + ) { + println("AABBCC OnDisconnected ${session.connectedControllers.size} ${session.id}") + pool.releaseSession(session) + } + } + + class MediaSessionExoPlayerConnector( + val mediaSession: MediaSession, + val pool: MediaSessionPool, + ) : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + println("AABBCC onIsPlayingChanged ${mediaSession.id} isPlaying $isPlaying") + 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/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/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..833b0434f 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 @@ -76,6 +76,8 @@ 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.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 +236,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() 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 } }, )