refactor: Migrate to MediaLibraryService

This commit is contained in:
Bnyro 2024-11-17 15:59:40 +01:00
parent 2d65b6d254
commit 17b642127c
26 changed files with 909 additions and 961 deletions

View File

@ -404,13 +404,49 @@
android:name=".services.OnlinePlayerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service
android:name=".services.OfflinePlayerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service
android:name=".services.VideoOnlinePlayerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service
android:name=".services.VideoOfflinePlayerService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service
android:name=".services.OnClearFromRecentService"

View File

@ -1,17 +1,23 @@
package com.github.libretube.api.obj
import android.graphics.drawable.Drawable
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
@Parcelize
data class ChapterSegment(
val title: String,
val image: String = "",
val start: Long,
// Used only for video highlights
@Transient var highlightDrawable: Drawable? = null
) {
@Transient
@IgnoredOnParcel
var highlightDrawable: Drawable? = null
): Parcelable {
companion object {
/**
* Length to show for a highlight in seconds

View File

@ -1,11 +1,14 @@
package com.github.libretube.api.obj
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class MetaInfo(
val title: String,
val description: String,
val urls: List<String>,
val urlTexts: List<String>
)
): Parcelable

View File

@ -1,11 +1,14 @@
package com.github.libretube.api.obj
import android.os.Parcelable
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlin.io.path.Path
@Serializable
@Parcelize
data class PipedStream(
var url: String? = null,
val format: String? = null,
@ -26,7 +29,7 @@ data class PipedStream(
val contentLength: Long = -1,
val audioTrackType: String? = null,
val audioTrackLocale: String? = null
) {
): Parcelable {
private fun getQualityString(fileName: String): String {
return "${fileName}_${quality?.replace(" ", "_")}_$format." +
mimeType?.split("/")?.last()

View File

@ -1,8 +1,11 @@
package com.github.libretube.api.obj
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class PreviewFrames(
val urls: List<String>,
val frameWidth: Int,
@ -11,4 +14,4 @@ data class PreviewFrames(
val durationPerFrame: Long,
val framesPerPageX: Int,
val framesPerPageY: Int
)
): Parcelable

View File

@ -1,5 +1,6 @@
package com.github.libretube.api.obj
import android.os.Parcelable
import com.github.libretube.db.obj.DownloadItem
import com.github.libretube.enums.FileType
import com.github.libretube.helpers.ProxyHelper
@ -8,18 +9,22 @@ import com.github.libretube.parcelable.DownloadData
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.io.path.Path
@Serializable
@Parcelize
data class Streams(
var title: String,
val description: String,
@Serializable(SafeInstantSerializer::class)
@SerialName("uploadDate")
val uploadTimestamp: Instant?,
@IgnoredOnParcel
val uploadTimestamp: Instant? = null,
val uploaded: Long? = null,
val uploader: String,
@ -48,7 +53,8 @@ data class Streams(
val chapters: List<ChapterSegment> = emptyList(),
val uploaderSubscriberCount: Long = 0,
val previewFrames: List<PreviewFrames> = emptyList()
) {
): Parcelable {
@IgnoredOnParcel
val isLive = livestream || duration <= 0
fun toDownloadItems(downloadData: DownloadData): List<DownloadItem> {

View File

@ -1,17 +1,20 @@
package com.github.libretube.api.obj
import android.content.Context
import android.os.Parcelable
import com.github.libretube.R
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
data class Subtitle(
val url: String? = null,
val mimeType: String? = null,
val name: String? = null,
val code: String? = null,
val autoGenerated: Boolean? = null
) {
): Parcelable {
fun getDisplayName(context: Context) = if (autoGenerated != true) {
name!!
} else {

View File

@ -55,4 +55,5 @@ object IntentData {
const val noInternet = "noInternet"
const val isPlayingOffline = "isPlayingOffline"
const val downloadInfo = "downloadInfo"
const val streams = "streams"
}

View File

@ -0,0 +1,11 @@
package com.github.libretube.enums
enum class PlayerCommand {
START_PLAYBACK,
SKIP_SILENCE,
SET_VIDEO_TRACK_TYPE_DISABLED,
SET_AUDIO_ROLE_FLAGS,
SET_RESOLUTION,
SET_AUDIO_LANGUAGE,
SET_SUBTITLE
}

View File

@ -1,22 +1,15 @@
package com.github.libretube.extensions
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.support.v4.media.MediaMetadataCompat
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import com.github.libretube.R
import com.github.libretube.api.obj.Streams
import com.github.libretube.db.obj.Download
fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
val appIcon = BitmapFactory.decodeResource(
Resources.getSystem(),
R.drawable.ic_launcher_monochrome
)
val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon,
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader
)
@ -29,3 +22,18 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
.build()
)
}
fun MediaItem.Builder.setMetadata(download: Download) = apply {
val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_TITLE to download.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to download.uploader
)
setMediaMetadata(
MediaMetadata.Builder()
.setTitle(download.title)
.setArtist(download.uploader)
.setArtworkUri(download.thumbnailPath?.toAndroidUri())
.setExtras(extras)
.build()
)
}

View File

@ -1,25 +1,34 @@
package com.github.libretube.helpers
import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.fragment.app.commit
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.github.libretube.constants.IntentData
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.services.OfflinePlayerService
import com.github.libretube.services.OnlinePlayerService
import com.github.libretube.services.VideoOfflinePlayerService
import com.github.libretube.services.VideoOnlinePlayerService
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.activities.NoInternetActivity
import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.ui.fragments.PlayerFragment
import com.google.common.util.concurrent.MoreExecutors
/**
* Helper for starting a new Instance of the [OnlinePlayerService]
*/
object BackgroundHelper {
/**
* Start the foreground service [OnlinePlayerService] to play in background. [position]
* is seek to position specified in milliseconds in the current [videoId].
@ -35,26 +44,31 @@ object BackgroundHelper {
) {
// close the previous video player if open
if (!keepVideoPlayerAlive) {
val fragmentManager = ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager
val fragmentManager =
ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager
fragmentManager.fragments.firstOrNull { it is PlayerFragment }?.let {
fragmentManager.commit { remove(it) }
}
}
// create an intent for the background mode service
val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position)
val intent = Intent(context, OnlinePlayerService::class.java)
.putExtra(IntentData.playerData, playerData)
// start the background mode as foreground service
ContextCompat.startForegroundService(context, intent)
val sessionToken =
SessionToken(context, ComponentName(context, OnlinePlayerService::class.java))
startMediaService(context, sessionToken, bundleOf(IntentData.playerData to playerData))
}
/**
* Stop the [OnlinePlayerService] service if it is running.
*/
fun stopBackgroundPlay(context: Context) {
arrayOf(OnlinePlayerService::class.java, OfflinePlayerService::class.java).forEach {
arrayOf(
OnlinePlayerService::class.java,
OfflinePlayerService::class.java,
VideoOfflinePlayerService::class.java,
VideoOnlinePlayerService::class.java
).forEach {
val intent = Intent(context, it)
context.stopService(intent)
}
@ -78,18 +92,46 @@ object BackgroundHelper {
* @param context the current context
* @param videoId the videoId of the video or null if all available downloads should be shuffled
*/
fun playOnBackgroundOffline(context: Context, videoId: String?, downloadTab: DownloadTab, shuffle: Boolean = false) {
fun playOnBackgroundOffline(
context: Context,
videoId: String?,
downloadTab: DownloadTab,
shuffle: Boolean = false
) {
stopBackgroundPlay(context)
// whether the service is started from the MainActivity or NoInternetActivity
val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null
val playerIntent = Intent(context, OfflinePlayerService::class.java)
.putExtra(IntentData.videoId, videoId)
.putExtra(IntentData.shuffle, shuffle)
.putExtra(IntentData.downloadTab, downloadTab)
.putExtra(IntentData.noInternet, noInternet)
val arguments = bundleOf(
IntentData.videoId to videoId,
IntentData.shuffle to shuffle,
IntentData.downloadTab to downloadTab,
IntentData.noInternet to noInternet
)
ContextCompat.startForegroundService(context, playerIntent)
val sessionToken =
SessionToken(context, ComponentName(context, OfflinePlayerService::class.java))
startMediaService(context, sessionToken, arguments)
}
@OptIn(UnstableApi::class)
fun startMediaService(
context: Context,
sessionToken: SessionToken,
arguments: Bundle,
onController: (MediaController) -> Unit = {}
) {
val controllerFuture =
MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture.addListener({
val controller = controllerFuture.get()
controller.sendCustomCommand(
AbstractPlayerService.startServiceCommand,
arguments
)
onController(controller)
}, MoreExecutors.directExecutor())
}
}

View File

@ -598,7 +598,7 @@ object PlayerHelper {
* @param segments List of the SponsorBlock segments
* @return If segment found and should skip manually, the end position of the segment in ms, otherwise null
*/
fun ExoPlayer.checkForSegments(
fun Player.checkForSegments(
context: Context,
segments: List<Segment>,
sponsorBlockConfig: MutableMap<String, SbSkipOptions>
@ -633,7 +633,7 @@ object PlayerHelper {
return null
}
fun ExoPlayer.isInSegment(segments: List<Segment>): Boolean {
fun Player.isInSegment(segments: List<Segment>): Boolean {
return segments.any {
val (start, end) = it.segmentStartAndEnd
val (segmentStart, segmentEnd) = (start * 1000f).toLong() to (end * 1000f).toLong()
@ -835,7 +835,7 @@ object PlayerHelper {
else -> R.drawable.ic_play
}
fun saveWatchPosition(player: ExoPlayer, videoId: String) {
fun saveWatchPosition(player: Player, videoId: String) {
if (player.duration == C.TIME_UNSET || player.currentPosition in listOf(0L, C.TIME_UNSET)) {
return
}

View File

@ -1,44 +1,47 @@
package com.github.libretube.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Binder
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.enums.NotificationId
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@UnstableApi
abstract class AbstractPlayerService : LifecycleService() {
var player: ExoPlayer? = null
var nowPlayingNotification: NowPlayingNotification? = null
abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySession.Callback {
private var mediaLibrarySession: MediaLibrarySession? = null
var exoPlayer: ExoPlayer? = null
private var nowPlayingNotification: NowPlayingNotification? = null
var trackSelector: DefaultTrackSelector? = null
lateinit var videoId: String
@ -76,7 +79,7 @@ abstract class AbstractPlayerService : LifecycleService() {
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
onStateOrPlayingChanged?.let { it(player?.isPlaying ?: false) }
onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) }
this@AbstractPlayerService.onPlaybackStateChanged(playbackState)
}
@ -96,103 +99,162 @@ abstract class AbstractPlayerService : LifecycleService() {
super.onEvents(player, events)
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
PlayerHelper.setPreferredAudioQuality(this@AbstractPlayerService, player, trackSelector ?: return)
PlayerHelper.setPreferredAudioQuality(
this@AbstractPlayerService,
player,
trackSelector ?: return
)
}
}
}
private val playerActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
val player = player ?: return
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
if (customCommand.customAction == START_SERVICE_ACTION) {
PlayingQueue.resetToDefaults()
if (PlayerHelper.handlePlayerAction(player, event)) return
when (event) {
PlayerEvent.Next -> {
PlayingQueue.navigateNext()
}
PlayerEvent.Prev -> {
PlayingQueue.navigatePrev()
}
PlayerEvent.Stop -> {
onDestroy()
}
else -> Unit
CoroutineScope(Dispatchers.IO).launch {
onServiceCreated(args)
startPlayback()
}
return super.onCustomCommand(session, controller, customCommand, args)
}
if (customCommand.customAction == RUN_PLAYER_COMMAND_ACTION) {
runPlayerCommand(args)
return super.onCustomCommand(session, controller, customCommand, args)
}
handlePlayerAction(PlayerEvent.valueOf(customCommand.customAction))
return super.onCustomCommand(session, controller, customCommand, args)
}
open fun runPlayerCommand(args: Bundle) {
when {
args.containsKey(PlayerCommand.SKIP_SILENCE.name) ->
exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
}
}
private fun handlePlayerAction(event: PlayerEvent) {
if (PlayerHelper.handlePlayerAction(exoPlayer ?: return, event)) return
when (event) {
PlayerEvent.Next -> {
PlayingQueue.navigateNext()
}
PlayerEvent.Prev -> {
PlayingQueue.navigatePrev()
}
PlayerEvent.Stop -> {
onDestroy()
}
else -> Unit
}
}
abstract val isOfflinePlayer: Boolean
abstract val isAudioOnlyPlayer: Boolean
abstract val intentActivity: Class<*>
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
mediaLibrarySession
override fun onCreate() {
super.onCreate()
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.playingOnBackground))
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.build()
startForeground(NotificationId.PLAYER_PLAYBACK.id, notification)
ContextCompat.registerReceiver(
val notificationProvider = NowPlayingNotification(
this,
playerActionReceiver,
IntentFilter(PlayerHelper.getIntentActionName(this)),
ContextCompat.RECEIVER_NOT_EXPORTED
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
PlayingQueue.resetToDefaults()
lifecycleScope.launch {
if (intent != null) {
onServiceCreated(intent)
createPlayerAndNotification()
startPlaybackAndUpdateNotification()
}
else stopSelf()
}
return super.onStartCommand(intent, flags, startId)
}
abstract suspend fun onServiceCreated(intent: Intent)
@OptIn(UnstableApi::class)
private fun createPlayerAndNotification() {
val trackSelector = DefaultTrackSelector(this)
this.trackSelector = trackSelector
trackSelector.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
}
player = PlayerHelper.createPlayer(this, trackSelector, true)
// prevent android from putting LibreTube to sleep when locked
player!!.setWakeMode(C.WAKE_MODE_LOCAL)
player!!.addListener(playerListener)
PlayerHelper.setPreferredCodecs(trackSelector)
nowPlayingNotification = NowPlayingNotification(
this,
player!!,
backgroundOnly = true,
offlinePlayer = isOfflinePlayer,
intentActivity = intentActivity
)
setMediaNotificationProvider(notificationProvider)
createPlayerAndMediaSession()
}
abstract suspend fun startPlaybackAndUpdateNotification()
abstract suspend fun onServiceCreated(args: Bundle)
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
// Select the button to display.
val customLayout = listOf(
CommandButton.Builder()
.setDisplayName(getString(R.string.rewind))
.setSessionCommand(SessionCommand(PlayerEvent.Prev.name, Bundle.EMPTY))
.setIconResId(R.drawable.ic_prev_outlined)
.build(),
CommandButton.Builder()
.setDisplayName(getString(R.string.play_next))
.setSessionCommand(SessionCommand(PlayerEvent.Next.name, Bundle.EMPTY))
.setIconResId(R.drawable.ic_next_outlined)
.build(),
)
val mediaNotificationSessionCommands =
connectionResult.availableSessionCommands.buildUpon()
.also { builder ->
builder.add(startServiceCommand)
builder.add(runPlayerActionCommand)
customLayout.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}
.build()
val playerCommands = connectionResult.availablePlayerCommands.buildUpon()
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
.setAvailablePlayerCommands(playerCommands)
.setCustomLayout(customLayout)
.build()
}
@OptIn(UnstableApi::class)
private fun createPlayerAndMediaSession() {
val trackSelector = DefaultTrackSelector(this)
this.trackSelector = trackSelector
if (isAudioOnlyPlayer) {
trackSelector.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
}
}
val player = PlayerHelper.createPlayer(this, trackSelector, true)
// prevent android from putting LibreTube to sleep when locked
player.setWakeMode(if (isOfflinePlayer) C.WAKE_MODE_LOCAL else C.WAKE_MODE_NETWORK)
player.addListener(playerListener)
this.exoPlayer = player
PlayerHelper.setPreferredCodecs(trackSelector)
mediaLibrarySession = MediaLibrarySession.Builder(this, player, this).build()
}
abstract suspend fun startPlayback()
fun saveWatchPosition() {
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
player?.let { PlayerHelper.saveWatchPosition(it, videoId) }
exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) }
}
override fun onDestroy() {
@ -200,20 +262,19 @@ abstract class AbstractPlayerService : LifecycleService() {
saveWatchPosition()
nowPlayingNotification?.destroySelf()
nowPlayingNotification = null
watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null)
runCatching {
player?.stop()
player?.release()
exoPlayer?.stop()
exoPlayer?.release()
}
player = null
runCatching {
unregisterReceiver(playerActionReceiver)
kotlin.runCatching {
mediaLibrarySession?.release()
mediaLibrarySession = null
}
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
@ -234,19 +295,27 @@ abstract class AbstractPlayerService : LifecycleService() {
abstract fun getChapters(): List<ChapterSegment>
fun getCurrentPosition() = player?.currentPosition
fun getCurrentPosition() = exoPlayer?.currentPosition
fun getDuration() = player?.duration
fun getDuration() = exoPlayer?.duration
fun seekToPosition(position: Long) = player?.seekTo(position)
fun seekToPosition(position: Long) = exoPlayer?.seekTo(position)
inner class LocalBinder : Binder() {
// Return this instance of [AbstractPlayerService] so clients can call public methods
fun getService(): AbstractPlayerService = this@AbstractPlayerService
}
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return binder
override fun onBind(intent: Intent?): IBinder {
// attempt to return [MediaLibraryServiceBinder] first if matched
return super.onBind(intent) ?: binder
}
companion object {
private const val START_SERVICE_ACTION = "start_service_action"
private const val RUN_PLAYER_COMMAND_ACTION = "run_player_command_action"
val startServiceCommand = SessionCommand(START_SERVICE_ACTION, Bundle.EMPTY)
val runPlayerActionCommand = SessionCommand(RUN_PLAYER_COMMAND_ACTION, Bundle.EMPTY)
}
}

View File

@ -1,7 +1,8 @@
package com.github.libretube.services
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
@ -12,15 +13,16 @@ import com.github.libretube.db.obj.DownloadChapter
import com.github.libretube.db.obj.DownloadWithItems
import com.github.libretube.db.obj.filterByTab
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.serializable
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.activities.NoInternetActivity
import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@ -30,9 +32,10 @@ import kotlin.io.path.exists
/**
* A service to play downloaded audio in the background
*/
@UnstableApi
class OfflinePlayerService : AbstractPlayerService() {
@OptIn(UnstableApi::class)
open class OfflinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = true
override val isAudioOnlyPlayer: Boolean = true
private var noInternetService: Boolean = false
override val intentActivity: Class<*>
get() = if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java
@ -41,17 +44,19 @@ class OfflinePlayerService : AbstractPlayerService() {
private lateinit var downloadTab: DownloadTab
private var shuffle: Boolean = false
override suspend fun onServiceCreated(intent: Intent) {
downloadTab = intent.serializableExtra(IntentData.downloadTab)!!
shuffle = intent.getBooleanExtra(IntentData.shuffle, false)
noInternetService = intent.getBooleanExtra(IntentData.noInternet, false)
private val scope = CoroutineScope(Dispatchers.Main)
override suspend fun onServiceCreated(args: Bundle) {
downloadTab = args.serializable(IntentData.downloadTab)!!
shuffle = args.getBoolean(IntentData.shuffle, false)
noInternetService = args.getBoolean(IntentData.noInternet, false)
videoId = if (shuffle) {
runBlocking(Dispatchers.IO) {
Database.downloadDao().getRandomVideoIdByFileType(FileType.AUDIO)
}
} else {
intent.getStringExtra(IntentData.videoId)
args.getString(IntentData.videoId)
} ?: return
PlayingQueue.clear()
@ -66,7 +71,7 @@ class OfflinePlayerService : AbstractPlayerService() {
/**
* Attempt to start an audio player with the given download items
*/
override suspend fun startPlaybackAndUpdateNotification() {
override suspend fun startPlayback() {
val downloadWithItems = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId)
}!!
@ -75,13 +80,20 @@ class OfflinePlayerService : AbstractPlayerService() {
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
val notificationData = PlayerNotificationData(
title = downloadWithItems.download.title,
uploaderName = downloadWithItems.download.uploader,
thumbnailPath = downloadWithItems.download.thumbnailPath
)
nowPlayingNotification?.updatePlayerNotification(videoId, notificationData)
withContext(Dispatchers.Main) {
setMediaItem(downloadWithItems)
exoPlayer?.playWhenReady = PlayerHelper.playAutomatically
exoPlayer?.prepare()
if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
exoPlayer?.seekTo(it)
}
}
}
}
open fun setMediaItem(downloadWithItems: DownloadWithItems) {
val audioItem = downloadWithItems.downloadItems.filter { it.path.exists() }
.firstOrNull { it.type == FileType.AUDIO }
?: // in some rare cases, video files can contain audio
@ -94,17 +106,10 @@ class OfflinePlayerService : AbstractPlayerService() {
val mediaItem = MediaItem.Builder()
.setUri(audioItem.path.toAndroidUri())
.setMetadata(downloadWithItems.download)
.build()
player?.setMediaItem(mediaItem)
player?.playWhenReady = PlayerHelper.playAutomatically
player?.prepare()
if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
player?.seekTo(it)
}
}
exoPlayer?.setMediaItem(mediaItem)
}
private suspend fun fillQueue() {
@ -124,8 +129,8 @@ class OfflinePlayerService : AbstractPlayerService() {
this.videoId = videoId
lifecycleScope.launch {
startPlaybackAndUpdateNotification()
scope.launch {
startPlayback()
}
}

View File

@ -1,8 +1,7 @@
package com.github.libretube.services
import android.content.Intent
import android.os.Bundle
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
@ -14,17 +13,17 @@ import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.extensions.parcelableExtra
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -36,6 +35,7 @@ import kotlinx.serialization.encodeToString
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class OnlinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = false
override val isAudioOnlyPlayer: Boolean = true
override val intentActivity: Class<*> = MainActivity::class.java
// PlaylistId/ChannelId for autoplay
@ -53,8 +53,10 @@ class OnlinePlayerService : AbstractPlayerService() {
private var sponsorBlockSegments = listOf<Segment>()
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
override suspend fun onServiceCreated(intent: Intent) {
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
private val scope = CoroutineScope(Dispatchers.IO)
override suspend fun onServiceCreated(args: Bundle) {
val playerData = args.parcelable<PlayerData>(IntentData.playerData)
if (playerData == null) {
stopSelf()
return
@ -72,7 +74,7 @@ class OnlinePlayerService : AbstractPlayerService() {
}
}
override suspend fun startPlaybackAndUpdateNotification() {
override suspend fun startPlayback() {
val timestamp = startTimestamp ?: 0L
startTimestamp = null
@ -110,30 +112,24 @@ class OnlinePlayerService : AbstractPlayerService() {
}
private fun playAudio(seekToPosition: Long) {
lifecycleScope.launch(Dispatchers.IO) {
scope.launch {
setMediaItem()
withContext(Dispatchers.Main) {
// seek to the previous position if available
if (seekToPosition != 0L) {
player?.seekTo(seekToPosition)
exoPlayer?.seekTo(seekToPosition)
} else if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let {
player?.seekTo(it)
exoPlayer?.seekTo(it)
}
}
}
}
val playerNotificationData = PlayerNotificationData(
streams?.title,
streams?.uploader,
streams?.thumbnailUrl
)
nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData)
streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) }
player?.apply {
exoPlayer?.apply {
playWhenReady = PlayerHelper.playAutomatically
prepare()
}
@ -146,7 +142,7 @@ class OnlinePlayerService : AbstractPlayerService() {
*/
private fun playNextVideo(nextId: String? = null) {
if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
player?.seekTo(0)
exoPlayer?.seekTo(0)
return
}
@ -161,13 +157,13 @@ class OnlinePlayerService : AbstractPlayerService() {
this.streams = null
this.sponsorBlockSegments = emptyList()
lifecycleScope.launch {
startPlaybackAndUpdateNotification()
scope.launch {
startPlayback()
}
}
/**
* Sets the [MediaItem] with the [streams] into the [player]
* Sets the [MediaItem] with the [streams] into the [exoPlayer]
*/
private suspend fun setMediaItem() {
val streams = streams ?: return
@ -185,14 +181,14 @@ class OnlinePlayerService : AbstractPlayerService() {
.setMimeType(mimeType)
.setMetadata(streams)
.build()
withContext(Dispatchers.Main) { player?.setMediaItem(mediaItem) }
withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) }
}
/**
* fetch the segments for SponsorBlock
*/
private fun fetchSponsorBlockSegments() {
lifecycleScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = RetrofitInstance.api.getSegments(
@ -210,7 +206,7 @@ class OnlinePlayerService : AbstractPlayerService() {
private fun checkForSegments() {
handler.postDelayed(this::checkForSegments, 100)
player?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -230,7 +226,7 @@ class OnlinePlayerService : AbstractPlayerService() {
// save video to watch history when the video starts playing or is being resumed
// waiting for the player to be ready since the video can't be claimed to be watched
// while it did not yet start actually, but did buffer only so far
lifecycleScope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
streams?.let { DatabaseHelper.addToWatchHistory(videoId, it) }
}
}

View File

@ -0,0 +1,85 @@
package com.github.libretube.services
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.FileDataSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import com.github.libretube.db.obj.DownloadWithItems
import com.github.libretube.enums.FileType
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.updateParameters
import kotlin.io.path.exists
@OptIn(UnstableApi::class)
class VideoOfflinePlayerService: OfflinePlayerService() {
override val isAudioOnlyPlayer = false
override fun setMediaItem(downloadWithItems: DownloadWithItems) {
val downloadFiles = downloadWithItems.downloadItems.filter { it.path.exists() }
val videoUri = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.toAndroidUri()
val audioUri = downloadFiles.firstOrNull { it.type == FileType.AUDIO }?.path?.toAndroidUri()
val subtitleInfo = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
val subtitle = subtitleInfo?.let {
SubtitleConfiguration.Builder(it.path.toAndroidUri())
.setMimeType(MimeTypes.APPLICATION_TTML)
.setLanguage(it.language ?: "en")
.build()
}
when {
videoUri != null && audioUri != null -> {
val videoItem = MediaItem.Builder()
.setUri(videoUri)
.setMetadata(downloadWithItems.download)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
val videoSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(videoItem)
val audioSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(MediaItem.fromUri(audioUri))
var mediaSource = MergingMediaSource(audioSource, videoSource)
if (subtitle != null) {
val subtitleSource = SingleSampleMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(subtitle, C.TIME_UNSET)
mediaSource = MergingMediaSource(mediaSource, subtitleSource)
}
exoPlayer?.setMediaSource(mediaSource)
}
videoUri != null -> exoPlayer?.setMediaItem(
MediaItem.Builder()
.setUri(videoUri)
.setMetadata(downloadWithItems.download)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
)
audioUri != null -> exoPlayer?.setMediaItem(
MediaItem.Builder()
.setUri(audioUri)
.setMetadata(downloadWithItems.download)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
)
}
trackSelector?.updateParameters {
setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
setPreferredTextLanguage(subtitle?.language)
}
}
}

View File

@ -0,0 +1,179 @@
package com.github.libretube.services
import android.net.Uri
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import com.github.libretube.R
import com.github.libretube.api.CronetHelper
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toastFromMainThread
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.YoutubeHlsPlaylistParser
import java.util.concurrent.Executors
@OptIn(UnstableApi::class)
class VideoOnlinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = false
override val isAudioOnlyPlayer: Boolean = false
override val intentActivity: Class<*> = MainActivity::class.java
private val cronetDataSourceFactory = CronetDataSource.Factory(
CronetHelper.cronetEngine,
Executors.newCachedThreadPool()
)
private lateinit var streams: Streams
override suspend fun onServiceCreated(args: Bundle) {
this.streams = args.parcelable<Streams>(IntentData.streams) ?: return
startPlayback()
}
override suspend fun startPlayback() = Unit
override fun runPlayerCommand(args: Bundle) {
when {
args.containsKey(PlayerCommand.START_PLAYBACK.name) -> setStreamSource()
args.containsKey(PlayerCommand.SKIP_SILENCE.name) -> exoPlayer?.skipSilenceEnabled = args.getBoolean(PlayerCommand.SKIP_SILENCE.name)
args.containsKey(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name) -> trackSelector?.updateParameters {
setTrackTypeDisabled(
C.TRACK_TYPE_VIDEO,
args.getBoolean(PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name)
)
}
args.containsKey(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name) -> {
trackSelector?.updateParameters {
setPreferredAudioRoleFlags(args.getInt(PlayerCommand.SET_AUDIO_ROLE_FLAGS.name))
}
}
args.containsKey(PlayerCommand.SET_AUDIO_LANGUAGE.name) -> {
trackSelector?.updateParameters {
setPreferredAudioLanguage(args.getString(PlayerCommand.SET_AUDIO_LANGUAGE.name))
}
}
args.containsKey(PlayerCommand.SET_RESOLUTION.name) -> {
trackSelector?.updateParameters {
val resolution = args.getInt(PlayerCommand.SET_RESOLUTION.name)
setMinVideoSize(Int.MIN_VALUE, resolution)
setMaxVideoSize(Int.MAX_VALUE, resolution)
}
}
args.containsKey(PlayerCommand.SET_SUBTITLE.name) -> {
updateCurrentSubtitle(args.parcelable(PlayerCommand.SET_SUBTITLE.name))
}
}
}
private fun setStreamSource() {
if (!this::streams.isInitialized) return
val (uri, mimeType) = when {
// LBRY HLS
PreferenceHelper.getBoolean(
PreferenceKeys.LBRY_HLS,
false
) && streams.videoStreams.any {
it.quality.orEmpty().contains("LBRY HLS")
} -> {
val lbryHlsUrl = streams.videoStreams.first {
it.quality!!.contains("LBRY HLS")
}.url!!
lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8
}
// DASH
!PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> {
// only use the dash manifest generated by YT if either it's a livestream or no other source is available
val dashUri =
if (streams.isLive && streams.dash != null) {
ProxyHelper.unwrapStreamUrl(
streams.dash!!
).toUri()
} else {
// skip LBRY urls when checking whether the stream source is usable
PlayerHelper.createDashSource(streams, this)
}
dashUri to MimeTypes.APPLICATION_MPD
}
// HLS
streams.hls != null -> {
val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory)
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
val mediaSource = hlsMediaSourceFactory.createMediaSource(
createMediaItem(
ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(),
MimeTypes.APPLICATION_M3U8
)
)
exoPlayer?.setMediaSource(mediaSource)
return
}
// NO STREAM FOUND
else -> {
toastFromMainThread(R.string.unknown_error)
return
}
}
setMediaSource(uri, mimeType)
}
private fun getSubtitleConfigs(): List<SubtitleConfiguration> = streams.subtitles.map {
val roleFlags = getSubtitleRoleFlags(it)
SubtitleConfiguration.Builder(it.url!!.toUri())
.setRoleFlags(roleFlags)
.setLanguage(it.code)
.setMimeType(it.mimeType).build()
}
private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder()
.setUri(uri)
.setMimeType(mimeType)
.setSubtitleConfigurations(getSubtitleConfigs())
.setMetadata(streams)
.build()
private fun setMediaSource(uri: Uri, mimeType: String) {
val mediaItem = createMediaItem(uri, mimeType)
exoPlayer?.setMediaItem(mediaItem)
}
private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
return if (subtitle?.autoGenerated != true) {
C.ROLE_FLAG_CAPTION
} else {
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
}
}
private fun updateCurrentSubtitle(subtitle: Subtitle?) =
trackSelector?.updateParameters {
val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0
setPreferredTextRoleFlags(roleFlags)
setPreferredTextLanguage(subtitle?.code)
}
override fun onPlaybackStateChanged(playbackState: Int) = Unit
override fun getChapters(): List<ChapterSegment> = emptyList()
}

View File

@ -1,29 +1,23 @@
package com.github.libretube.ui.activities
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle
import android.text.format.DateUtils
import android.view.KeyEvent
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.FileDataSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.media3.ui.PlayerView
import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.compat.PictureInPictureParamsCompat
@ -36,19 +30,16 @@ import com.github.libretube.db.obj.filterByTab
import com.github.libretube.enums.FileType
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.toAndroidUri
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.WindowHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.services.VideoOfflinePlayerService
import com.github.libretube.ui.base.BaseActivity
import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.ui.interfaces.TimeFrameReceiver
import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.ChaptersViewModel
import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.OfflinePlayerViewModel
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.OfflineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
@ -61,15 +52,15 @@ import kotlin.io.path.exists
class OfflinePlayerActivity : BaseActivity() {
private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var videoId: String
private lateinit var playerController: MediaController
private lateinit var playerView: PlayerView
private var timeFrameReceiver: TimeFrameReceiver? = null
private var nowPlayingNotification: NowPlayingNotification? = null
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
private val viewModel: OfflinePlayerViewModel by viewModels { OfflinePlayerViewModel.Factory }
private val chaptersViewModel: ChaptersViewModel by viewModels()
private val watchPositionTimer = PauseableTimer(
onTick = this::saveWatchPosition,
delayMillis = PlayerHelper.WATCH_POSITION_TIMER_DELAY_MS
@ -82,6 +73,13 @@ class OfflinePlayerActivity : BaseActivity() {
playerBinding.duration.text = DateUtils.formatElapsedTime(
player.duration / 1000
)
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
requestedOrientation = PlayerHelper.getOrientation(
playerController.videoSize.width,
playerController.videoSize.height
)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -110,7 +108,7 @@ class OfflinePlayerActivity : BaseActivity() {
SeekbarPreviewListener(
timeFrameReceiver ?: return,
binding.player.binding,
viewModel.player.duration
playerController.duration
)
)
}
@ -124,7 +122,7 @@ class OfflinePlayerActivity : BaseActivity() {
private val playerActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return
if (PlayerHelper.handlePlayerAction(playerController, event)) return
when (event) {
PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return)
@ -135,17 +133,23 @@ class OfflinePlayerActivity : BaseActivity() {
}
private val pipParams
get() = PictureInPictureParamsCompat.Builder()
.setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying))
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
.setAspectRatio(viewModel.player.videoSize)
.build()
get() = run {
val isPlaying = ::playerController.isInitialized && playerController.isPlaying
PictureInPictureParamsCompat.Builder()
.setActions(PlayerHelper.getPiPModeActions(this,isPlaying))
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
.apply {
if (isPlaying) {
setAspectRatio(playerController.videoSize)
}
}
.build()
}
override fun onCreate(savedInstanceState: Bundle?) {
WindowHelper.toggleFullscreen(window, true)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
super.onCreate(savedInstanceState)
videoId = intent?.getStringExtra(IntentData.videoId)!!
@ -160,13 +164,19 @@ class OfflinePlayerActivity : BaseActivity() {
playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
}
initializePlayer()
playVideo()
requestedOrientation = PlayerHelper.getOrientation(
viewModel.player.videoSize.width,
viewModel.player.videoSize.height
val sessionToken = SessionToken(
this,
ComponentName(this, VideoOfflinePlayerService::class.java)
)
val arguments = bundleOf(
IntentData.downloadTab to DownloadTab.VIDEO,
IntentData.videoId to videoId
)
BackgroundHelper.startMediaService(this, sessionToken, arguments) {
playerController = it
playerController.addListener(playerListener)
initializePlayerView()
}
ContextCompat.registerReceiver(
this,
@ -188,14 +198,11 @@ class OfflinePlayerActivity : BaseActivity() {
playVideo()
}
private fun initializePlayer() {
viewModel.player.setWakeMode(C.WAKE_MODE_LOCAL)
viewModel.player.addListener(playerListener)
private fun initializePlayerView() {
playerView = binding.player
playerView.setShowSubtitleButton(true)
playerView.subtitleView?.isVisible = true
playerView.player = viewModel.player
playerView.player = playerController
playerBinding = binding.player.binding
playerBinding.fullscreen.isInvisible = true
@ -216,13 +223,6 @@ class OfflinePlayerActivity : BaseActivity() {
binding.playerGestureControlsView.binding,
chaptersViewModel
)
nowPlayingNotification = NowPlayingNotification(
this,
viewModel.player,
offlinePlayer = true,
intentActivity = OfflinePlayerActivity::class.java
)
}
private fun playVideo() {
@ -230,7 +230,6 @@ class OfflinePlayerActivity : BaseActivity() {
val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId)
}!!
PlayingQueue.updateCurrent(downloadInfo.toStreamItem())
val chapters = downloadChapters.map(DownloadChapter::toChapterSegment)
chaptersViewModel.chaptersLiveData.value = chapters
@ -240,88 +239,15 @@ class OfflinePlayerActivity : BaseActivity() {
playerBinding.exoTitle.text = downloadInfo.title
playerBinding.exoTitle.isVisible = true
val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO }
val audio = downloadFiles.firstOrNull { it.type == FileType.AUDIO }
val subtitle = downloadFiles.firstOrNull { it.type == FileType.SUBTITLE }
val videoUri = video?.path?.toAndroidUri()
val audioUri = audio?.path?.toAndroidUri()
val subtitleUri = subtitle?.path?.toAndroidUri()
setMediaSource(videoUri, audioUri, subtitleUri)
viewModel.trackSelector.updateParameters {
setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
setPreferredTextLanguage("en")
}
timeFrameReceiver = video?.path?.let {
timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
}
viewModel.player.playWhenReady = PlayerHelper.playAutomatically
viewModel.player.prepare()
if (PlayerHelper.watchPositionsVideo) {
PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let {
viewModel.player.seekTo(it)
playerController.seekTo(it)
}
}
val data = PlayerNotificationData(
downloadInfo.title,
downloadInfo.uploader,
downloadInfo.thumbnailPath.toString()
)
nowPlayingNotification?.updatePlayerNotification(videoId, data)
}
}
private fun setMediaSource(videoUri: Uri?, audioUri: Uri?, subtitleUri: Uri?) {
val subtitle = subtitleUri?.let {
SubtitleConfiguration.Builder(it)
.setMimeType(MimeTypes.APPLICATION_TTML)
.setLanguage("en")
.build()
}
when {
videoUri != null && audioUri != null -> {
val videoItem = MediaItem.Builder()
.setUri(videoUri)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
val videoSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(videoItem)
val audioSource = ProgressiveMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(MediaItem.fromUri(audioUri))
var mediaSource = MergingMediaSource(audioSource, videoSource)
if (subtitle != null) {
val subtitleSource = SingleSampleMediaSource.Factory(FileDataSource.Factory())
.createMediaSource(subtitle, C.TIME_UNSET)
mediaSource = MergingMediaSource(mediaSource, subtitleSource)
}
viewModel.player.setMediaSource(mediaSource)
}
videoUri != null -> viewModel.player.setMediaItem(
MediaItem.Builder()
.setUri(videoUri)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
)
audioUri != null -> viewModel.player.setMediaItem(
MediaItem.Builder()
.setUri(audioUri)
.setSubtitleConfigurations(listOfNotNull(subtitle))
.build()
)
}
}
@ -336,7 +262,7 @@ class OfflinePlayerActivity : BaseActivity() {
private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return
PlayerHelper.saveWatchPosition(viewModel.player, videoId)
PlayerHelper.saveWatchPosition(playerController, videoId)
}
override fun onResume() {
@ -349,19 +275,17 @@ class OfflinePlayerActivity : BaseActivity() {
super.onPause()
if (PlayerHelper.pauseOnQuit) {
viewModel.player.pause()
playerController.pause()
}
}
override fun onDestroy() {
saveWatchPosition()
nowPlayingNotification?.destroySelf()
nowPlayingNotification = null
watchPositionTimer.destroy()
runCatching {
viewModel.player.stop()
playerController.stop()
}
runCatching {
@ -372,7 +296,7 @@ class OfflinePlayerActivity : BaseActivity() {
}
override fun onUserLeaveHint() {
if (PlayerHelper.pipEnabled && viewModel.player.isPlaying) {
if (PlayerHelper.pipEnabled && playerController.isPlaying) {
PictureInPictureCompat.enterPictureInPictureMode(this, pipParams)
}

View File

@ -155,10 +155,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
it.text = (PlayerHelper.seekIncrement / 1000).toString()
}
binding.rewindFL.setOnClickListener {
playerService?.player?.seekBy(-PlayerHelper.seekIncrement)
playerService?.exoPlayer?.seekBy(-PlayerHelper.seekIncrement)
}
binding.forwardFL.setOnClickListener {
playerService?.player?.seekBy(PlayerHelper.seekIncrement)
playerService?.exoPlayer?.seekBy(PlayerHelper.seekIncrement)
}
binding.openQueue.setOnClickListener {
@ -166,7 +166,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
}
binding.playbackOptions.setOnClickListener {
playerService?.player?.let {
playerService?.exoPlayer?.let {
PlaybackOptionsSheet(it)
.show(childFragmentManager)
}
@ -182,7 +182,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
NavigationHelper.navigateVideo(
context = requireContext(),
videoUrlOrId = PlayingQueue.getCurrent()?.url,
timestamp = playerService?.player?.currentPosition?.div(1000) ?: 0,
timestamp = playerService?.exoPlayer?.currentPosition?.div(1000) ?: 0,
keepQueue = true,
forceVideo = true
)
@ -192,7 +192,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
ChaptersBottomSheet.SEEK_TO_POSITION_REQUEST_KEY,
viewLifecycleOwner
) { _, bundle ->
playerService?.player?.seekTo(bundle.getLong(IntentData.currentPosition))
playerService?.exoPlayer?.seekTo(bundle.getLong(IntentData.currentPosition))
}
binding.openChapters.setOnClickListener {
@ -202,7 +202,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
ChaptersBottomSheet()
.apply {
arguments = bundleOf(
IntentData.duration to playerService.player?.duration?.div(1000)
IntentData.duration to playerService.exoPlayer?.duration?.div(1000)
)
}
.show(childFragmentManager)
@ -218,11 +218,11 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
binding.thumbnail.setOnTouchListener(listener)
binding.playPause.setOnClickListener {
playerService?.player?.togglePlayPauseState()
playerService?.exoPlayer?.togglePlayPauseState()
}
binding.miniPlayerPause.setOnClickListener {
playerService?.player?.togglePlayPauseState()
playerService?.exoPlayer?.togglePlayPauseState()
}
binding.showMore.setOnClickListener {
@ -381,7 +381,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
}
private fun updatePlayPauseButton() {
playerService?.player?.let {
playerService?.exoPlayer?.let {
val binding = _binding ?: return
val iconRes = PlayerHelper.getPlayPauseActionIcon(it)
@ -396,8 +396,10 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
isPaused = !isPlaying
}
playerService?.onNewVideoStarted = { streamItem ->
updateStreamInfo(streamItem)
_binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty()
handler.post {
updateStreamInfo(streamItem)
_binding?.openChapters?.isVisible = !playerService?.getChapters().isNullOrEmpty()
}
}
initializeSeekBar()
@ -422,7 +424,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
}
override fun onSingleTap() {
playerService?.player?.togglePlayPauseState()
playerService?.exoPlayer?.togglePlayPauseState()
}
override fun onLongTap() {
@ -479,7 +481,7 @@ class AudioPlayerFragment : Fragment(), AudioPlayerOptions {
if (_binding == null) return
handler.postDelayed(this::updateChapterIndex, 100)
val player = playerService?.player ?: return
val player = playerService?.exoPlayer ?: return
val currentIndex =
PlayerHelper.getCurrentChapterIndex(player.currentPosition, chaptersModel.chapters)

View File

@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
@ -11,7 +12,6 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@ -45,16 +45,12 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.SubtitleConfiguration
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.R
import com.github.libretube.api.CronetHelper
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
@ -66,17 +62,16 @@ import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.FragmentPlayerBinding
import com.github.libretube.db.DatabaseHelper
import com.github.libretube.db.DatabaseHolder
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.enums.ShareObjectType
import com.github.libretube.extensions.formatShort
import com.github.libretube.extensions.parcelable
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.setMetadata
import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.extensions.togglePlayPauseState
import com.github.libretube.extensions.updateIfChanged
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.helpers.DownloadHelper
import com.github.libretube.helpers.ImageHelper
@ -85,16 +80,16 @@ import com.github.libretube.helpers.NavBarHelper
import com.github.libretube.helpers.NavigationHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.PlayerHelper.getVideoStats
import com.github.libretube.helpers.PlayerHelper.isInSegment
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.helpers.ThemeHelper
import com.github.libretube.helpers.WindowHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.obj.ShareData
import com.github.libretube.obj.VideoResolution
import com.github.libretube.parcelable.PlayerData
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.services.VideoOnlinePlayerService
import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.ui.adapters.VideosAdapter
import com.github.libretube.ui.base.BaseActivity
@ -111,19 +106,15 @@ import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.sheets.BaseBottomSheet
import com.github.libretube.ui.sheets.CommentsSheet
import com.github.libretube.ui.sheets.StatsSheet
import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.OnlineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue
import com.github.libretube.util.TextUtils
import com.github.libretube.util.TextUtils.toTimeInSeconds
import com.github.libretube.util.YoutubeHlsPlaylistParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
import kotlin.math.abs
import kotlin.math.ceil
@ -138,9 +129,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
private val playerGestureControlsViewBinding get() = binding.playerGestureControlsView.binding
private val commonPlayerViewModel: CommonPlayerViewModel by activityViewModels()
private val viewModel: PlayerViewModel by viewModels { PlayerViewModel.Factory }
private val viewModel: PlayerViewModel by viewModels()
private val commentsViewModel: CommentsViewModel by activityViewModels()
private val chaptersViewModel: ChaptersViewModel by activityViewModels()
private lateinit var playerController: MediaController
// Video information passed by the intent
private lateinit var videoId: String
@ -161,10 +153,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// if null, use same quality as fullscreen
private var noFullscreenResolution: Int? = null
private val cronetDataSourceFactory = CronetDataSource.Factory(
CronetHelper.cronetEngine,
Executors.newCachedThreadPool()
)
private var selectedAudioLanguageAndRoleFlags: Pair<String?, @C. RoleFlags Int>? = null
private val handler = Handler(Looper.getMainLooper())
@ -211,7 +200,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onReceive(context: Context, intent: Intent) {
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return
if (PlayerHelper.handlePlayerAction(playerController, event)) return
when (event) {
PlayerEvent.Next -> {
@ -291,14 +280,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
) {
updatePlayPauseButton()
}
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
PlayerHelper.setPreferredAudioQuality(
requireContext(),
viewModel.player,
viewModel.trackSelector
)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
@ -306,9 +287,9 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// set the playback speed to one if having reached the end of a livestream
if (playbackState == Player.STATE_BUFFERING && binding.player.isLive &&
viewModel.player.duration - viewModel.player.currentPosition < 700
playerController.duration - playerController.currentPosition < 700
) {
viewModel.player.setPlaybackSpeed(1f)
playerController.setPlaybackSpeed(1f)
}
// check if video has ended, next video is available and autoplay is enabled/the video is part of a played playlist.
@ -342,7 +323,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (playbackState == Player.STATE_BUFFERING) {
if (bufferingTimeoutTask == null) {
bufferingTimeoutTask = Runnable {
viewModel.player.pause()
playerController.pause()
}
}
@ -360,7 +341,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
try {
viewModel.player.play()
playerController.play()
} catch (e: Exception) {
e.printStackTrace()
}
@ -406,6 +387,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
fullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), true)
noFullscreenResolution = PlayerHelper.getDefaultResolution(requireContext(), false)
val sessionToken = SessionToken(
requireContext(),
ComponentName(requireContext(), VideoOnlinePlayerService::class.java)
)
BackgroundHelper.startMediaService(requireContext(), sessionToken, bundleOf()) {
playerController = it
playerController.addListener(playerListener)
}
}
override fun onCreateView(
@ -431,7 +421,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
playerLayoutOrientation = resources.configuration.orientation
createExoPlayer()
initializeTransitionLayout()
initializeOnClickActions()
@ -591,7 +580,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
binding.playImageView.setOnClickListener {
viewModel.player.togglePlayPauseState()
playerController.togglePlayPauseState()
}
activity?.supportFragmentManager
@ -626,7 +615,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
IntentData.shareObjectType to ShareObjectType.VIDEO,
IntentData.shareData to ShareData(
currentVideo = streams.title,
currentPosition = viewModel.player.currentPosition / 1000
currentPosition = playerController.currentPosition / 1000
)
)
val newShareDialog = ShareDialog()
@ -653,7 +642,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
binding.relPlayerBackground.setOnClickListener {
// pause the current player
viewModel.player.pause()
playerController.pause()
// start the background mode
playOnBackground()
@ -712,7 +701,8 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
PixelCopy.request(surfaceView, bmp, { _ ->
screenshotBitmap = bmp
val currentPosition = viewModel.player.currentPosition.toFloat() / 1000
val currentPosition =
playerController.currentPosition.toFloat() / 1000
openScreenshotFile.launch("${streams.title}-${currentPosition}.png")
}, Handler(Looper.getMainLooper()))
}
@ -743,7 +733,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
BackgroundHelper.playOnBackground(
requireContext(),
videoId,
viewModel.player.currentPosition,
playerController.currentPosition,
playlistId,
channelId,
keepQueue = true,
@ -756,8 +746,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
private fun updateFullscreenOrientation() {
if (PlayerHelper.autoFullscreenEnabled || !this::streams.isInitialized) return
val height = streams.videoStreams.firstOrNull()?.height ?: viewModel.player.videoSize.height
val width = streams.videoStreams.firstOrNull()?.width ?: viewModel.player.videoSize.width
val height = streams.videoStreams.firstOrNull()?.height
?: playerController.videoSize.height
val width =
streams.videoStreams.firstOrNull()?.width ?: playerController.videoSize.width
mainActivity.requestedOrientation = PlayerHelper.getOrientation(width, height)
}
@ -839,14 +831,16 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// disable video stream since it's not needed when screen off
if (!isInteractive) {
viewModel.trackSelector.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
}
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to true
)
)
}
// pause player if screen off and setting enabled
if (!isInteractive && PlayerHelper.pausePlayerOnScreenOffEnabled) {
viewModel.player.pause()
playerController.pause()
}
// the app was put somewhere in the background - remember to not automatically continue
@ -864,12 +858,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (closedVideo) {
closedVideo = false
viewModel.nowPlayingNotification?.refreshNotification()
}
// re-enable and load video stream
viewModel.trackSelector.updateParameters {
setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false)
if (::playerController.isInitialized) {
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_VIDEO_TRACK_TYPE_DISABLED.name to false
)
)
}
}
@ -878,13 +875,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
saveWatchPosition()
viewModel.nowPlayingNotification?.destroySelf()
viewModel.nowPlayingNotification = null
watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null)
viewModel.player.removeListener(playerListener)
viewModel.player.pause()
playerController.removeListener(playerListener)
playerController.pause()
if (PlayerHelper.pipEnabled) {
// disable the auto PiP mode for SDK >= 32
@ -940,17 +935,17 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// save the watch position if video isn't finished and option enabled
private fun saveWatchPosition() {
if (!isPlayerTransitioning && PlayerHelper.watchPositionsVideo) {
PlayerHelper.saveWatchPosition(viewModel.player, videoId)
PlayerHelper.saveWatchPosition(playerController, videoId)
}
}
private fun checkForSegments() {
if (!viewModel.player.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
if (!playerController.isPlaying || !PlayerHelper.sponsorBlockEnabled) return
handler.postDelayed(this::checkForSegments, 100)
if (!viewModel.sponsorBlockEnabled || viewModel.segments.isEmpty()) return
viewModel.player.checkForSegments(
playerController.checkForSegments(
requireContext(),
viewModel.segments,
viewModel.sponsorBlockConfig
@ -959,12 +954,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (commonPlayerViewModel.isMiniPlayerVisible.value == true) return@let
binding.sbSkipBtn.isVisible = true
binding.sbSkipBtn.setOnClickListener {
viewModel.player.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
playerController.seekTo((segment.segmentStartAndEnd.second * 1000f).toLong())
segment.skipped = true
}
return
}
if (!viewModel.player.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone = true
if (!playerController.isInSegment(viewModel.segments)) binding.sbSkipBtn.isGone =
true
}
private fun playVideo() {
@ -983,6 +979,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
this@PlayerFragment.streams = streams!!
playerController.sendCustomCommand(
AbstractPlayerService.startServiceCommand,
bundleOf(IntentData.streams to streams)
)
}
val isFirstVideo = PlayingQueue.isEmpty()
@ -1019,17 +1019,18 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
binding.player.apply {
useController = false
player = viewModel.player
player = playerController
}
initializePlayerView()
// don't continue playback when the fragment is re-created after Android killed it
val wasIntentStopped = requireArguments().getBoolean(IntentData.wasIntentStopped, false)
viewModel.player.playWhenReady = PlayerHelper.playAutomatically && !wasIntentStopped
playerController.playWhenReady =
PlayerHelper.playAutomatically && !wasIntentStopped
requireArguments().putBoolean(IntentData.wasIntentStopped, false)
viewModel.player.prepare()
playerController.prepare()
if (binding.playerMotionLayout.progress != 1.0f) {
// show controllers when not in picture in picture mode
@ -1039,13 +1040,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
binding.player.useController = true
}
}
// show the player notification
initializePlayerNotification()
fetchSponsorBlockSegments()
if (streams.category == Streams.categoryMusic) {
viewModel.player.setPlaybackSpeed(1f)
playerController.setPlaybackSpeed(1f)
}
viewModel.isOrientationChangeInProgress = false
@ -1076,7 +1075,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
*/
private fun playNextVideo(nextId: String? = null) {
if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
viewModel.player.seekTo(0)
playerController.seekTo(0)
return
}
@ -1117,7 +1116,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
viewModel,
commonPlayerViewModel,
viewLifecycleOwner,
viewModel.trackSelector,
this
)
@ -1206,7 +1204,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
if (videoId == this.videoId) {
// try finding the time stamp of the url and seek to it if found
uri.getQueryParameter("t")?.toTimeInSeconds()?.let {
viewModel.player.seekTo(it * 1000)
playerController.seekTo(it * 1000)
}
} else {
// YouTube video link without time or not the current video, thus load in player
@ -1215,7 +1213,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
private fun updatePlayPauseButton() {
binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(viewModel.player))
binding.playImageView.setImageResource(PlayerHelper.getPlayPauseActionIcon(playerController))
}
private suspend fun initializeHighlight(highlight: Segment) {
@ -1234,31 +1232,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
)
}
private fun getSubtitleConfigs(): List<SubtitleConfiguration> = streams.subtitles.map {
val roleFlags = getSubtitleRoleFlags(it)
SubtitleConfiguration.Builder(it.url!!.toUri())
.setRoleFlags(roleFlags)
.setLanguage(it.code)
.setMimeType(it.mimeType).build()
}
private fun createMediaItem(uri: Uri, mimeType: String) = MediaItem.Builder()
.setUri(uri)
.setMimeType(mimeType)
.setSubtitleConfigurations(getSubtitleConfigs())
.setMetadata(streams)
.build()
private fun setMediaSource(uri: Uri, mimeType: String) {
val mediaItem = createMediaItem(uri, mimeType)
viewModel.player.setMediaItem(mediaItem)
}
/**
* Get all available player resolutions
*/
private fun getAvailableResolutions(): List<VideoResolution> {
val resolutions = viewModel.player.currentTracks.groups.asSequence()
val resolutions = playerController.currentTracks.groups.asSequence()
.flatMap { group ->
(0 until group.length).map {
group.getTrackFormat(it).height
@ -1274,29 +1252,31 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
private fun initStreamSources() {
// use the video's default audio track when starting playback
viewModel.trackSelector.updateParameters {
setPreferredAudioRoleFlags(C.ROLE_FLAG_MAIN)
}
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to C.ROLE_FLAG_MAIN
)
)
// set the default subtitle if available
updateCurrentSubtitle(viewModel.currentSubtitle)
// set media source and resolution in the beginning
lifecycleScope.launch(Dispatchers.IO) {
setStreamSource()
updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true)
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand,
bundleOf(PlayerCommand.START_PLAYBACK.name to true)
)
withContext(Dispatchers.Main) {
// support for time stamped links
if (timeStamp != 0L) {
viewModel.player.seekTo(timeStamp * 1000)
// delete the time stamp because it already got consumed
timeStamp = 0L
} else if (!streams.isLive) {
// seek to the saved watch position
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
viewModel.player.seekTo(it)
}
}
// support for time stamped links
if (timeStamp != 0L) {
playerController.seekTo(timeStamp * 1000)
// delete the time stamp because it already got consumed
timeStamp = 0L
} else if (!streams.isLive) {
// seek to the saved watch position
PlayerHelper.getStoredWatchPosition(videoId, streams.duration)?.let {
playerController.seekTo(it)
}
}
}
@ -1308,10 +1288,13 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
resolution
}
viewModel.trackSelector.updateParameters {
setMaxVideoSize(Int.MAX_VALUE, transformedResolution)
setMinVideoSize(Int.MIN_VALUE, transformedResolution)
}
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_RESOLUTION.name to transformedResolution
)
)
binding.player.selectedResolution = resolution
}
private fun updateResolutionOnFullscreenChange(isFullscreen: Boolean) {
@ -1322,86 +1305,6 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
}
private suspend fun setStreamSource() {
updateResolutionOnFullscreenChange(commonPlayerViewModel.isFullscreen.value == true)
val (uri, mimeType) = when {
// LBRY HLS
PreferenceHelper.getBoolean(
PreferenceKeys.LBRY_HLS,
false
) && streams.videoStreams.any {
it.quality.orEmpty().contains("LBRY HLS")
} -> {
val lbryHlsUrl = streams.videoStreams.first {
it.quality!!.contains("LBRY HLS")
}.url!!
lbryHlsUrl.toUri() to MimeTypes.APPLICATION_M3U8
}
// DASH
!PlayerHelper.useHlsOverDash && streams.videoStreams.isNotEmpty() -> {
// only use the dash manifest generated by YT if either it's a livestream or no other source is available
val dashUri =
if (streams.isLive && streams.dash != null) {
ProxyHelper.unwrapStreamUrl(
streams.dash!!
).toUri()
} else {
// skip LBRY urls when checking whether the stream source is usable
PlayerHelper.createDashSource(streams, requireContext())
}
dashUri to MimeTypes.APPLICATION_MPD
}
// HLS
streams.hls != null -> {
val hlsMediaSourceFactory = HlsMediaSource.Factory(cronetDataSourceFactory)
.setPlaylistParserFactory(YoutubeHlsPlaylistParser.Factory())
val mediaSource = hlsMediaSourceFactory.createMediaSource(
createMediaItem(
ProxyHelper.unwrapStreamUrl(streams.hls!!).toUri(),
MimeTypes.APPLICATION_M3U8
)
)
withContext(Dispatchers.Main) { viewModel.player.setMediaSource(mediaSource) }
return
}
// NO STREAM FOUND
else -> {
context?.toastFromMainDispatcher(R.string.unknown_error)
return
}
}
withContext(Dispatchers.Main) { setMediaSource(uri, mimeType) }
}
private fun createExoPlayer() {
viewModel.player.setWakeMode(C.WAKE_MODE_NETWORK)
viewModel.player.addListener(playerListener)
// control for the track sources like subtitles and audio source
PlayerHelper.setPreferredCodecs(viewModel.trackSelector)
}
/**
* show the [NowPlayingNotification] for the current video
*/
private fun initializePlayerNotification() {
if (viewModel.nowPlayingNotification == null) {
viewModel.nowPlayingNotification = NowPlayingNotification(
requireContext(),
viewModel.player
)
}
val playerNotificationData = PlayerNotificationData(
streams.title,
streams.uploader,
streams.thumbnailUrl
)
viewModel.nowPlayingNotification?.updatePlayerNotification(videoId, playerNotificationData)
}
/**
* Use the sensor mode if auto fullscreen is enabled
*/
@ -1438,24 +1341,15 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
.show(childFragmentManager)
}
private fun getSubtitleRoleFlags(subtitle: Subtitle?): Int {
return if (subtitle?.autoGenerated != true) {
C.ROLE_FLAG_CAPTION
} else {
PlayerHelper.ROLE_FLAG_AUTO_GEN_SUBTITLE
}
}
override fun onQualityClicked() {
// get the available resolutions
val resolutions = getAvailableResolutions()
val currentQuality = viewModel.trackSelector.parameters.maxVideoHeight
// Dialog for quality selection
BaseBottomSheet()
.setSimpleItems(
resolutions.map(VideoResolution::name),
preselectedItem = resolutions.firstOrNull { it.resolution == currentQuality }?.name
preselectedItem = resolutions.firstOrNull { it.resolution == binding.player.selectedResolution }?.name
) { which ->
val newResolution = resolutions[which].resolution
setPlayerResolution(newResolution, true)
@ -1473,7 +1367,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onAudioStreamClicked() {
val context = requireContext()
val audioLanguagesAndRoleFlags = PlayerHelper.getAudioLanguagesAndRoleFlagsFromTrackGroups(
viewModel.player.currentTracks.groups,
playerController.currentTracks.groups,
false
)
val audioLanguages = audioLanguagesAndRoleFlags.map {
@ -1499,18 +1393,22 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
} else {
baseBottomSheet.setSimpleItems(
audioLanguages,
preselectedItem = audioLanguagesAndRoleFlags.firstOrNull {
val format = viewModel.player.audioFormat
format?.language == it.first && format?.roleFlags == it.second
}?.let {
preselectedItem = selectedAudioLanguageAndRoleFlags?.let {
PlayerHelper.getAudioTrackNameFromFormat(context, it)
},
) { index ->
val selectedAudioFormat = audioLanguagesAndRoleFlags[index]
viewModel.trackSelector.updateParameters {
setPreferredAudioLanguage(selectedAudioFormat.first)
setPreferredAudioRoleFlags(selectedAudioFormat.second)
}
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_AUDIO_ROLE_FLAGS.name to selectedAudioFormat.second
)
)
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_AUDIO_LANGUAGE.name to selectedAudioFormat.first
)
)
selectedAudioLanguageAndRoleFlags = selectedAudioFormat
}
}
@ -1523,10 +1421,11 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
override fun onStatsClicked() {
if (!this::streams.isInitialized) return
val videoStats = getVideoStats(viewModel.player, videoId)
StatsSheet()
.apply { arguments = bundleOf(IntentData.videoStats to videoStats) }
.show(childFragmentManager)
// TODO: reimplement video stats
// val videoStats = getVideoStats(playerController, videoId)
// StatsSheet()
// .apply { arguments = bundleOf(IntentData.videoStats to videoStats) }
// .show(childFragmentManager)
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
@ -1545,8 +1444,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
// close button got clicked in PiP mode
// pause the video and keep the app alive
if (lifecycle.currentState == Lifecycle.State.CREATED) {
viewModel.player.pause()
viewModel.nowPlayingNotification?.cancelNotification()
playerController.pause()
closedVideo = true
}
@ -1559,36 +1457,43 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
}
private fun updateCurrentSubtitle(subtitle: Subtitle?) =
viewModel.trackSelector.updateParameters {
val roleFlags = if (subtitle?.code != null) getSubtitleRoleFlags(subtitle) else 0
setPreferredTextRoleFlags(roleFlags)
setPreferredTextLanguage(subtitle?.code)
}
private fun updateCurrentSubtitle(subtitle: Subtitle?) {
if (!::playerController.isInitialized) return
playerController.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand, bundleOf(
PlayerCommand.SET_SUBTITLE.name to subtitle
)
)
}
fun onUserLeaveHint() {
if (shouldStartPiP()) {
PictureInPictureCompat.enterPictureInPictureMode(requireActivity(), pipParams)
} else if (PlayerHelper.pauseOnQuit) {
viewModel.player.pause()
playerController.pause()
}
}
private val pipParams
get() = PictureInPictureParamsCompat.Builder()
.setActions(
PlayerHelper.getPiPModeActions(
requireActivity(),
viewModel.player.isPlaying
private val pipParams: PictureInPictureParamsCompat
get() = run {
val isPlaying = ::playerController.isInitialized && playerController.isPlaying
PictureInPictureParamsCompat.Builder()
.setActions(
PlayerHelper.getPiPModeActions(
requireActivity(),
isPlaying
)
)
)
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
.apply {
if (viewModel.player.isPlaying) {
setAspectRatio(viewModel.player.videoSize)
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
.apply {
if (isPlaying) {
setAspectRatio(playerController.videoSize)
}
}
}
.build()
.build()
}
private fun createSeekbarPreviewListener(): SeekbarPreviewListener {
return SeekbarPreviewListener(
@ -1606,7 +1511,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
}
private fun shouldStartPiP(): Boolean {
return shouldUsePip() && viewModel.player.isPlaying &&
return shouldUsePip() && playerController.isPlaying &&
!BackgroundHelper.isBackgroundServiceRunning(requireContext())
}
@ -1620,7 +1525,10 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions {
val orientation = resources.configuration.orientation
if (commonPlayerViewModel.isFullscreen.value != true && orientation != playerLayoutOrientation) {
// remember the current position before recreating the activity
arguments?.putLong(IntentData.timeStamp, viewModel.player.currentPosition / 1000)
arguments?.putLong(
IntentData.timeStamp,
playerController.currentPosition / 1000
)
playerLayoutOrientation = orientation
viewModel.isOrientationChangeInProgress = true

View File

@ -1,35 +0,0 @@
package com.github.libretube.ui.models
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.github.libretube.helpers.PlayerHelper
@UnstableApi
class OfflinePlayerViewModel(
val player: ExoPlayer,
val trackSelector: DefaultTrackSelector,
) : ViewModel() {
companion object {
val Factory = viewModelFactory {
initializer {
val context = this[APPLICATION_KEY]!!
val trackSelector = DefaultTrackSelector(context)
OfflinePlayerViewModel(
player = PlayerHelper.createPlayer(context, trackSelector, false),
trackSelector = trackSelector,
)
}
}
}
override fun onCleared() {
super.onCleared()
player.release()
}
}

View File

@ -2,17 +2,10 @@ package com.github.libretube.ui.models
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.Message
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
@ -24,10 +17,7 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
@UnstableApi
class PlayerViewModel(
val player: ExoPlayer,
val trackSelector: DefaultTrackSelector,
) : ViewModel() {
class PlayerViewModel : ViewModel() {
// data to remember for recovery on orientation change
private var streamsInfo: Streams? = null
@ -69,22 +59,4 @@ class PlayerViewModel(
).segments
}
}
companion object {
val Factory = viewModelFactory {
initializer {
val context = this[APPLICATION_KEY]!!
val trackSelector = DefaultTrackSelector(context)
PlayerViewModel(
player = PlayerHelper.createPlayer(context, trackSelector, false),
trackSelector = trackSelector,
)
}
}
}
override fun onCleared() {
super.onCleared()
player.release()
}
}

View File

@ -4,17 +4,22 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaController
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.databinding.PlaybackBottomSheetBinding
import com.github.libretube.enums.PlayerCommand
import com.github.libretube.extensions.round
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.ui.adapters.SliderLabelsAdapter
class PlaybackOptionsSheet(
private val player: ExoPlayer
private val player: Player
) : ExpandedBottomSheet() {
private var _binding: PlaybackBottomSheetBinding? = null
private val binding get() = _binding!!
@ -32,8 +37,10 @@ class PlaybackOptionsSheet(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val binding = binding
binding.speedShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
binding.pitchShortcuts.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
binding.speedShortcuts.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
binding.pitchShortcuts.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
binding.speedShortcuts.adapter = SliderLabelsAdapter(SUGGESTED_SPEEDS) {
binding.speed.value = it
@ -57,7 +64,15 @@ class PlaybackOptionsSheet(
}
binding.skipSilence.setOnCheckedChangeListener { _, isChecked ->
player.skipSilenceEnabled = isChecked
// TODO: unify the skip silence handling
if (player is ExoPlayer) {
player.skipSilenceEnabled = isChecked
} else if (player is MediaController) {
player.sendCustomCommand(
AbstractPlayerService.runPlayerActionCommand,
bundleOf(PlayerCommand.SKIP_SILENCE.name to isChecked)
)
}
PreferenceHelper.putBoolean(PreferenceKeys.SKIP_SILENCE, isChecked)
}
}

View File

@ -31,7 +31,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.text.Cue
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaController
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
@ -601,8 +601,8 @@ abstract class CustomExoPlayerView(
}
override fun onPlaybackSpeedClicked() {
player?.let {
PlaybackOptionsSheet(it as ExoPlayer).show(supportFragmentManager)
(player as? MediaController)?.let {
PlaybackOptionsSheet(it).show(supportFragmentManager)
}
}

View File

@ -14,7 +14,6 @@ import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.trackselection.TrackSelector
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.constants.PreferenceKeys
@ -29,7 +28,6 @@ import com.github.libretube.ui.dialogs.SubmitSegmentDialog
import com.github.libretube.ui.interfaces.OnlinePlayerOptions
import com.github.libretube.ui.models.CommonPlayerViewModel
import com.github.libretube.ui.models.PlayerViewModel
import com.github.libretube.ui.sheets.PlayingQueueSheet
import com.github.libretube.util.PlayingQueue
@UnstableApi
@ -40,7 +38,6 @@ class OnlinePlayerView(
private var playerOptions: OnlinePlayerOptions? = null
private var playerViewModel: PlayerViewModel? = null
private var commonPlayerViewModel: CommonPlayerViewModel? = null
private var trackSelector: TrackSelector? = null
private var viewLifecycleOwner: LifecycleOwner? = null
private val handler = Handler(Looper.getMainLooper())
@ -51,6 +48,8 @@ class OnlinePlayerView(
*/
var currentWindow: Window? = null
var selectedResolution: Int? = null
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override fun getOptionsMenuItems(): List<BottomSheetItem> {
return super.getOptionsMenuItems() +
@ -72,7 +71,7 @@ class OnlinePlayerView(
BottomSheetItem(
context.getString(R.string.captions),
R.drawable.ic_caption,
this::getCurrentCaptionLanguage
{ playerViewModel?.currentSubtitle?.code ?: context.getString(R.string.none) }
) {
playerOptions?.onCaptionsClicked()
},
@ -89,25 +88,14 @@ class OnlinePlayerView(
private fun getCurrentResolutionSummary(): String {
val currentQuality = player?.videoSize?.height ?: 0
var summary = "${currentQuality}p"
val trackSelector = trackSelector ?: return summary
val selectedQuality = trackSelector.parameters.maxVideoHeight
if (selectedQuality == Int.MAX_VALUE) {
if (selectedResolution == null) {
summary += " - ${context.getString(R.string.auto)}"
} else if (selectedQuality > currentQuality) {
} else if ((selectedResolution ?: 0) > currentQuality) {
summary += " - ${context.getString(R.string.resolution_limited)}"
}
return summary
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun getCurrentCaptionLanguage(): String {
return if (trackSelector != null && trackSelector!!.parameters.preferredTextLanguages.isNotEmpty()) {
trackSelector!!.parameters.preferredTextLanguages[0]
} else {
context.getString(R.string.none)
}
}
private fun getCurrentAudioTrackTitle(): String {
if (player == null) {
return context.getString(R.string.unknown_or_no_audio)
@ -153,13 +141,11 @@ class OnlinePlayerView(
playerViewModel: PlayerViewModel,
commonPlayerViewModel: CommonPlayerViewModel,
viewLifecycleOwner: LifecycleOwner,
trackSelector: TrackSelector,
playerOptions: OnlinePlayerOptions
) {
this.playerViewModel = playerViewModel
this.commonPlayerViewModel = commonPlayerViewModel
this.viewLifecycleOwner = viewLifecycleOwner
this.trackSelector = trackSelector
this.playerOptions = playerOptions
commonPlayerViewModel.isFullscreen.observe(viewLifecycleOwner) { isFullscreen ->

View File

@ -1,75 +1,35 @@
package com.github.libretube.util
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import androidx.media.app.NotificationCompat.MediaStyle
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
import com.github.libretube.R
import com.github.libretube.constants.IntentData
import com.github.libretube.enums.NotificationId
import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.toMediaMetadataCompat
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.services.OnClearFromRecentService
import com.github.libretube.ui.activities.MainActivity
import java.util.UUID
import com.google.common.collect.ImmutableList
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class NowPlayingNotification(
private val context: Context,
private val player: ExoPlayer,
private val backgroundOnly: Boolean = false,
private val offlinePlayer: Boolean = false,
private val intentActivity: Class<*> = MainActivity::class.java
) {
private var videoId: String? = null
private val nManager = context.getSystemService<NotificationManager>()!!
/**
* The metadata of the current playing song (thumbnail, title, uploader)
*/
private var notificationData: PlayerNotificationData? = null
/**
* The [MediaSessionCompat] for the [notificationData].
*/
private lateinit var mediaSession: MediaSessionCompat
/**
* The [NotificationCompat.Builder] to load the [mediaSession] content on it.
*/
private var notificationBuilder: NotificationCompat.Builder? = null
/**
* The [Bitmap] which represents the background / thumbnail of the notification
*/
private var notificationBitmap: Bitmap? = null
private fun loadCurrentLargeIcon() {
if (DataSaverMode.isEnabled(context)) return
if (notificationBitmap == null) {
enqueueThumbnailRequest {
createOrUpdateNotification()
}
}
}
): MediaNotification.Provider {
private val nProvider = DefaultMediaNotificationProvider.Builder(context)
.setNotificationId(NotificationId.PLAYER_PLAYBACK.id)
.setChannelId(PLAYER_CHANNEL_NAME)
.setChannelName(R.string.player_channel_name)
.build()
private fun createCurrentContentIntent(): PendingIntent? {
// starts a new MainActivity Intent when the player notification is clicked
@ -84,194 +44,10 @@ class NowPlayingNotification(
}
}
return PendingIntentCompat
.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false)
}
private fun createIntent(action: String): PendingIntent? {
val intent = Intent(action)
.setPackage(context.packageName)
return PendingIntentCompat
.getBroadcast(context, 1, intent, PendingIntent.FLAG_CANCEL_CURRENT, false)
}
private fun enqueueThumbnailRequest(callback: (Bitmap) -> Unit) {
ImageHelper.getImageWithCallback(
context,
notificationData?.thumbnailPath?.toString() ?: notificationData?.thumbnailUrl
) {
notificationBitmap = processBitmap(it)
callback.invoke(notificationBitmap!!)
}
}
private fun processBitmap(bitmap: Bitmap): Bitmap {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bitmap
} else {
ImageHelper.getSquareBitmap(bitmap)
}
}
private val legacyNotificationButtons
get() = listOf(
createNotificationAction(R.drawable.ic_prev_outlined, PlayerEvent.Prev.name),
createNotificationAction(
if (player.isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
PlayerEvent.PlayPause.name
),
createNotificationAction(R.drawable.ic_next_outlined, PlayerEvent.Next.name),
createNotificationAction(R.drawable.ic_rewind_md, PlayerEvent.Rewind.name),
createNotificationAction(R.drawable.ic_forward_md, PlayerEvent.Forward.name)
)
private fun createNotificationAction(
drawableRes: Int,
actionName: String
): NotificationCompat.Action {
return NotificationCompat.Action.Builder(drawableRes, actionName, createIntent(actionName))
.build()
}
private fun createMediaSessionAction(
@DrawableRes drawableRes: Int,
actionName: String
): PlaybackStateCompat.CustomAction {
return PlaybackStateCompat.CustomAction.Builder(actionName, actionName, drawableRes).build()
}
/**
* Creates a [MediaSessionCompat] for the player
*/
private fun createMediaSession() {
if (this::mediaSession.isInitialized) return
val sessionCallback = object : MediaSessionCompat.Callback() {
override fun onRewind() {
handlePlayerAction(PlayerEvent.Rewind)
super.onRewind()
}
override fun onFastForward() {
handlePlayerAction(PlayerEvent.Forward)
super.onFastForward()
}
override fun onPlay() {
handlePlayerAction(PlayerEvent.PlayPause)
super.onPlay()
}
override fun onPause() {
handlePlayerAction(PlayerEvent.PlayPause)
super.onPause()
}
override fun onSkipToNext() {
handlePlayerAction(PlayerEvent.Next)
super.onSkipToNext()
}
override fun onSkipToPrevious() {
handlePlayerAction(PlayerEvent.Prev)
super.onSkipToPrevious()
}
override fun onStop() {
handlePlayerAction(PlayerEvent.Stop)
super.onStop()
}
override fun onSeekTo(pos: Long) {
player.seekTo(pos)
super.onSeekTo(pos)
}
override fun onCustomAction(action: String, extras: Bundle?) {
runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) }
super.onCustomAction(action, extras)
}
}
mediaSession = MediaSessionCompat(context, UUID.randomUUID().toString())
mediaSession.setCallback(sessionCallback)
updateSessionMetadata()
updateSessionPlaybackState()
val playerStateListener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
updateSessionPlaybackState(isPlaying = isPlaying)
}
override fun onIsLoadingChanged(isLoading: Boolean) {
super.onIsLoadingChanged(isLoading)
if (!isLoading) {
updateSessionMetadata()
}
updateSessionPlaybackState(isLoading = isLoading)
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
super.onMediaMetadataChanged(mediaMetadata)
updateSessionMetadata(mediaMetadata)
}
}
player.addListener(playerStateListener)
}
private fun updateSessionMetadata(metadata: MediaMetadata? = null) {
val data = metadata ?: player.mediaMetadata
val newMetadata = data.toMediaMetadataCompat(player.duration, notificationBitmap)
mediaSession.setMetadata(newMetadata)
}
private fun updateSessionPlaybackState(isPlaying: Boolean? = null, isLoading: Boolean? = null) {
val loading = isLoading == true || (isPlaying == false && player.isLoading)
val newPlaybackState = when {
loading -> PlaybackStateCompat.STATE_BUFFERING
isPlaying ?: player.isPlaying -> PlaybackStateCompat.STATE_PLAYING
else -> PlaybackStateCompat.STATE_PAUSED
}
mediaSession.setPlaybackState(createPlaybackState(newPlaybackState))
}
private fun createPlaybackState(@PlaybackStateCompat.State state: Int): PlaybackStateCompat {
val stateActions = PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_SEEK_TO
return PlaybackStateCompat.Builder()
.setActions(stateActions)
.addCustomAction(
createMediaSessionAction(
R.drawable.ic_rewind_md,
PlayerEvent.Rewind.name
)
)
.addCustomAction(
createMediaSessionAction(
R.drawable.ic_forward_md,
PlayerEvent.Forward.name
)
)
.setState(state, player.currentPosition, player.playbackParameters.speed)
.build()
}
/**
* Forward the action to the responsible notification owner (e.g. PlayerFragment)
*/
@ -282,79 +58,23 @@ class NowPlayingNotification(
context.sendBroadcast(intent)
}
/**
* Updates or creates the [notificationBuilder]
*/
fun updatePlayerNotification(videoId: String, data: PlayerNotificationData) {
this.videoId = videoId
this.notificationData = data
// reset the thumbnail bitmap in order to become reloaded for the new video
this.notificationBitmap = null
loadCurrentLargeIcon()
if (notificationBuilder == null) {
createMediaSession()
createNotificationBuilder()
// update the notification each time the player continues playing or pauses
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
createOrUpdateNotification()
super.onIsPlayingChanged(isPlaying)
}
})
context.startService(Intent(context, OnClearFromRecentService::class.java))
}
createOrUpdateNotification()
override fun createNotification(
mediaSession: MediaSession,
customLayout: ImmutableList<CommandButton>,
actionFactory: MediaNotification.ActionFactory,
onNotificationChangedCallback: MediaNotification.Provider.Callback
): MediaNotification {
createCurrentContentIntent()?.let { mediaSession.setSessionActivity(it) }
nProvider.setSmallIcon(R.drawable.ic_launcher_lockscreen)
return nProvider.createNotification(mediaSession, customLayout, actionFactory, onNotificationChangedCallback)
}
/**
* Initializes the [notificationBuilder] attached to the [player] and shows it.
*/
private fun createNotificationBuilder() {
notificationBuilder = NotificationCompat.Builder(context, PLAYER_CHANNEL_NAME)
.setSmallIcon(R.drawable.ic_launcher_lockscreen)
.setContentIntent(createCurrentContentIntent())
.setDeleteIntent(createIntent(PlayerEvent.Stop.name))
.setStyle(
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(1)
)
}
private fun createOrUpdateNotification() {
if (notificationBuilder == null) return
val notification = notificationBuilder!!
.setContentTitle(notificationData?.title)
.setContentText(notificationData?.uploaderName)
.setLargeIcon(notificationBitmap)
.clearActions()
.apply {
legacyNotificationButtons.forEach {
addAction(it)
}
}
.build()
updateSessionMetadata()
nManager.notify(NotificationId.PLAYER_PLAYBACK.id, notification)
}
/**
* Destroy the [NowPlayingNotification]
*/
fun destroySelf() {
if (this::mediaSession.isInitialized) mediaSession.release()
nManager.cancel(NotificationId.PLAYER_PLAYBACK.id)
}
fun cancelNotification() {
nManager.cancel(NotificationId.PLAYER_PLAYBACK.id)
}
fun refreshNotification() {
createOrUpdateNotification()
override fun handleCustomCommand(
session: MediaSession,
action: String,
extras: Bundle
): Boolean {
runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) }
return true
}
}