- Refactoring playback service and its client.

- Created a pool of ExoPlayers to avoid creating new instances when scrolling.
- More accurately tracks MediaSessions
- Splits Caching layers into their own files
- Splits VideoView into multiple files
- Specializes the old Playback Manager into pools.
- Reorganizes video playback dependencies inside a single package.
- Structures callback uris to be per media item as opposed to per controller.
This commit is contained in:
Vitor Pamplona 2025-03-17 13:53:26 -04:00
parent c1399d129e
commit e3e35fa1eb
52 changed files with 3039 additions and 1743 deletions

View File

@ -122,7 +122,7 @@
tools:replace="screenOrientation" />
<service
android:name=".service.playback.PlaybackService"
android:name=".service.playback.service.PlaybackService"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="true"
android:exported="true">

View File

@ -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

View File

@ -22,15 +22,25 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
object MediaAspectRatioCache {
val mediaAspectRatioCacheByUrl = LruCache<String, Float>(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<String, Float>(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())

View File

@ -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<String, MediaSession>()
private val cache =
object : LruCache<String, MediaSession>(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<MediaSession> = playingMap.values
}

View File

@ -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<Int, SessionToken>(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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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<MediaControllerState?>(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)
}
}

View File

@ -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<Boolean>,
isClosestToTheCenterOfTheScreen: MutableState<Boolean>,
) {
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)
}
}
}

View File

@ -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<MediaItem>,
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)
}
}
}

View File

@ -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<Boolean, Drawable?>>(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,
)
}
}
}

View File

@ -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<MediaController?> = mutableStateOf(null),
// this set's the stage to keep playing on the background or not when the user leaves the screen
val keepPlaying: MutableState<Boolean> = mutableStateOf(false),
// this will be false if the screen leaves before the controller connection comes back from the service.
val active: MutableState<Boolean> = mutableStateOf(true),
// this will be set to own when the controller is ready
val readyToDisplay: MutableState<Boolean> = mutableStateOf(false),
// isCurrentlyBeingRendered
val composed: MutableState<Boolean> = mutableStateOf(false),
) {
fun isPlaying() = controller.value?.isPlaying == true
fun currrentMedia() = controller.value?.currentMediaItem?.mediaId
fun isActive() = active.value == true
fun needsController() = controller.value == null
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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<String, Long>(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?,
)

View File

@ -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<Boolean>(
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,
)
}
}
}
}

View File

@ -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<Boolean>,
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,
)
}
}
}
}

View File

@ -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<Boolean>,
controllerVisible: MutableState<Boolean>,
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)
}
}
}
}
}

View File

@ -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<Boolean>,
startingMuteState: Boolean,
modifier: Modifier,
toggle: (Boolean) -> Unit,
) {
val holdOn =
remember {
mutableStateOf<Boolean>(
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,
)
}

View File

@ -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<Boolean>,
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)
},
)
}

View File

@ -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<Boolean>,
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),
)
}
}
}

View File

@ -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<Boolean>,
modifier: Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
ShareButton(innerAction)
}
}
@Composable
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> 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 }
}
}
}

View File

@ -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<VisibilityData>()
@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<Boolean>) -> Unit,
) {
val myCache = remember(controller) { VisibilityData() }
// Is the current video the closest to the center?
val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf<Boolean>(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
}

View File

@ -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<MediaItem>) -> 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<MediaItem>) -> Unit,
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
val myState = remember(data.videoUri) { mutableStateOf(it) }
inner(myState)
}
}

View File

@ -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<MediaItemData, MediaItem>(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()
}

View File

@ -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,
)

View File

@ -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 },
)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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<ExoPlayer>()
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()
}
}
}
}

View File

@ -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<String, SessionListener>()
private val cache =
object : LruCache<String, SessionListener>(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<MediaItem>,
): ListenableFuture<List<MediaItem>> {
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)
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<String, Long>(100)
override fun add(
uri: String,
position: Long,
) {
cachedPosition.put(uri, position)
}
override fun get(uri: String): Long? = cachedPosition.get(uri)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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()
}

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
},
)