mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-04-23 15:04:51 +02:00
- 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:
parent
c1399d129e
commit
e3e35fa1eb
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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?,
|
||||
)
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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,
|
||||
)
|
@ -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 },
|
||||
)
|
||||
}
|
@ -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)
|
@ -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)
|
||||
|
@ -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)
|
@ -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))
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user