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:name=".services.OnlinePlayerService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
<service <service
android:name=".services.OfflinePlayerService" android:name=".services.OfflinePlayerService"
android:enabled="true" android:enabled="true"
android:exported="false" 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 <service
android:name=".services.OnClearFromRecentService" android:name=".services.OnClearFromRecentService"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,4 +55,5 @@ object IntentData {
const val noInternet = "noInternet" const val noInternet = "noInternet"
const val isPlayingOffline = "isPlayingOffline" const val isPlayingOffline = "isPlayingOffline"
const val downloadInfo = "downloadInfo" 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 package com.github.libretube.extensions
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import com.github.libretube.R
import com.github.libretube.api.obj.Streams import com.github.libretube.api.obj.Streams
import com.github.libretube.db.obj.Download
fun MediaItem.Builder.setMetadata(streams: Streams) = apply { fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
val appIcon = BitmapFactory.decodeResource(
Resources.getSystem(),
R.drawable.ic_launcher_monochrome
)
val extras = bundleOf( val extras = bundleOf(
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON to appIcon,
MediaMetadataCompat.METADATA_KEY_TITLE to streams.title, MediaMetadataCompat.METADATA_KEY_TITLE to streams.title,
MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader MediaMetadataCompat.METADATA_KEY_ARTIST to streams.uploader
) )
@ -29,3 +22,18 @@ fun MediaItem.Builder.setMetadata(streams: Streams) = apply {
.build() .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 package com.github.libretube.helpers
import android.app.ActivityManager import android.app.ActivityManager
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent 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.content.getSystemService
import androidx.core.os.bundleOf
import androidx.fragment.app.commit 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.constants.IntentData
import com.github.libretube.parcelable.PlayerData import com.github.libretube.parcelable.PlayerData
import com.github.libretube.services.AbstractPlayerService
import com.github.libretube.services.OfflinePlayerService import com.github.libretube.services.OfflinePlayerService
import com.github.libretube.services.OnlinePlayerService 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.MainActivity
import com.github.libretube.ui.activities.NoInternetActivity import com.github.libretube.ui.activities.NoInternetActivity
import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.ui.fragments.PlayerFragment import com.github.libretube.ui.fragments.PlayerFragment
import com.google.common.util.concurrent.MoreExecutors
/** /**
* Helper for starting a new Instance of the [OnlinePlayerService] * Helper for starting a new Instance of the [OnlinePlayerService]
*/ */
object BackgroundHelper { object BackgroundHelper {
/** /**
* Start the foreground service [OnlinePlayerService] to play in background. [position] * Start the foreground service [OnlinePlayerService] to play in background. [position]
* is seek to position specified in milliseconds in the current [videoId]. * is seek to position specified in milliseconds in the current [videoId].
@ -35,26 +44,31 @@ object BackgroundHelper {
) { ) {
// close the previous video player if open // close the previous video player if open
if (!keepVideoPlayerAlive) { if (!keepVideoPlayerAlive) {
val fragmentManager = ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager val fragmentManager =
ContextHelper.unwrapActivity<MainActivity>(context).supportFragmentManager
fragmentManager.fragments.firstOrNull { it is PlayerFragment }?.let { fragmentManager.fragments.firstOrNull { it is PlayerFragment }?.let {
fragmentManager.commit { remove(it) } fragmentManager.commit { remove(it) }
} }
} }
// create an intent for the background mode service
val playerData = PlayerData(videoId, playlistId, channelId, keepQueue, position) 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 val sessionToken =
ContextCompat.startForegroundService(context, intent) SessionToken(context, ComponentName(context, OnlinePlayerService::class.java))
startMediaService(context, sessionToken, bundleOf(IntentData.playerData to playerData))
} }
/** /**
* Stop the [OnlinePlayerService] service if it is running. * Stop the [OnlinePlayerService] service if it is running.
*/ */
fun stopBackgroundPlay(context: Context) { 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) val intent = Intent(context, it)
context.stopService(intent) context.stopService(intent)
} }
@ -78,18 +92,46 @@ object BackgroundHelper {
* @param context the current context * @param context the current context
* @param videoId the videoId of the video or null if all available downloads should be shuffled * @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) stopBackgroundPlay(context)
// whether the service is started from the MainActivity or NoInternetActivity // whether the service is started from the MainActivity or NoInternetActivity
val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null val noInternet = ContextHelper.tryUnwrapActivity<NoInternetActivity>(context) != null
val playerIntent = Intent(context, OfflinePlayerService::class.java) val arguments = bundleOf(
.putExtra(IntentData.videoId, videoId) IntentData.videoId to videoId,
.putExtra(IntentData.shuffle, shuffle) IntentData.shuffle to shuffle,
.putExtra(IntentData.downloadTab, downloadTab) IntentData.downloadTab to downloadTab,
.putExtra(IntentData.noInternet, noInternet) 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 * @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 * @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, context: Context,
segments: List<Segment>, segments: List<Segment>,
sponsorBlockConfig: MutableMap<String, SbSkipOptions> sponsorBlockConfig: MutableMap<String, SbSkipOptions>
@ -633,7 +633,7 @@ object PlayerHelper {
return null return null
} }
fun ExoPlayer.isInSegment(segments: List<Segment>): Boolean { fun Player.isInSegment(segments: List<Segment>): Boolean {
return segments.any { return segments.any {
val (start, end) = it.segmentStartAndEnd val (start, end) = it.segmentStartAndEnd
val (segmentStart, segmentEnd) = (start * 1000f).toLong() to (end * 1000f).toLong() val (segmentStart, segmentEnd) = (start * 1000f).toLong() to (end * 1000f).toLong()
@ -835,7 +835,7 @@ object PlayerHelper {
else -> R.drawable.ic_play 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)) { if (player.duration == C.TIME_UNSET || player.currentPosition in listOf(0L, C.TIME_UNSET)) {
return return
} }

View File

@ -1,44 +1,47 @@
package com.github.libretube.services package com.github.libretube.services
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Binder import android.os.Binder
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat 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.C
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector 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.R
import com.github.libretube.api.obj.ChapterSegment import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.StreamItem 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.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.updateParameters import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.util.NowPlayingNotification import com.github.libretube.util.NowPlayingNotification
import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue 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 import kotlinx.coroutines.launch
@UnstableApi @UnstableApi
abstract class AbstractPlayerService : LifecycleService() { abstract class AbstractPlayerService : MediaLibraryService(), MediaLibrarySession.Callback {
var player: ExoPlayer? = null private var mediaLibrarySession: MediaLibrarySession? = null
var nowPlayingNotification: NowPlayingNotification? = null var exoPlayer: ExoPlayer? = null
private var nowPlayingNotification: NowPlayingNotification? = null
var trackSelector: DefaultTrackSelector? = null var trackSelector: DefaultTrackSelector? = null
lateinit var videoId: String lateinit var videoId: String
@ -76,7 +79,7 @@ abstract class AbstractPlayerService : LifecycleService() {
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState) super.onPlaybackStateChanged(playbackState)
onStateOrPlayingChanged?.let { it(player?.isPlaying ?: false) } onStateOrPlayingChanged?.let { it(exoPlayer?.isPlaying ?: false) }
this@AbstractPlayerService.onPlaybackStateChanged(playbackState) this@AbstractPlayerService.onPlaybackStateChanged(playbackState)
} }
@ -96,103 +99,162 @@ abstract class AbstractPlayerService : LifecycleService() {
super.onEvents(player, events) super.onEvents(player, events)
if (events.contains(Player.EVENT_TRACKS_CHANGED)) { 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 onCustomCommand(
override fun onReceive(context: Context, intent: Intent) { session: MediaSession,
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return controller: MediaSession.ControllerInfo,
val player = player ?: return customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
if (customCommand.customAction == START_SERVICE_ACTION) {
PlayingQueue.resetToDefaults()
if (PlayerHelper.handlePlayerAction(player, event)) return 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) { when (event) {
PlayerEvent.Next -> { PlayerEvent.Next -> {
PlayingQueue.navigateNext() PlayingQueue.navigateNext()
} }
PlayerEvent.Prev -> { PlayerEvent.Prev -> {
PlayingQueue.navigatePrev() PlayingQueue.navigatePrev()
} }
PlayerEvent.Stop -> { PlayerEvent.Stop -> {
onDestroy() onDestroy()
} }
else -> Unit else -> Unit
} }
} }
}
abstract val isOfflinePlayer: Boolean abstract val isOfflinePlayer: Boolean
abstract val isAudioOnlyPlayer: Boolean
abstract val intentActivity: Class<*> abstract val intentActivity: Class<*>
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? =
mediaLibrarySession
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val notification = NotificationCompat.Builder(this, PLAYER_CHANNEL_NAME) val notificationProvider = NowPlayingNotification(
.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(
this, 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, backgroundOnly = true,
offlinePlayer = isOfflinePlayer, offlinePlayer = isOfflinePlayer,
intentActivity = intentActivity 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() { fun saveWatchPosition() {
if (isTransitioning || !PlayerHelper.watchPositionsVideo) return if (isTransitioning || !PlayerHelper.watchPositionsVideo) return
player?.let { PlayerHelper.saveWatchPosition(it, videoId) } exoPlayer?.let { PlayerHelper.saveWatchPosition(it, videoId) }
} }
override fun onDestroy() { override fun onDestroy() {
@ -200,20 +262,19 @@ abstract class AbstractPlayerService : LifecycleService() {
saveWatchPosition() saveWatchPosition()
nowPlayingNotification?.destroySelf()
nowPlayingNotification = null nowPlayingNotification = null
watchPositionTimer.destroy() watchPositionTimer.destroy()
handler.removeCallbacksAndMessages(null) handler.removeCallbacksAndMessages(null)
runCatching { runCatching {
player?.stop() exoPlayer?.stop()
player?.release() exoPlayer?.release()
} }
player = null
runCatching { kotlin.runCatching {
unregisterReceiver(playerActionReceiver) mediaLibrarySession?.release()
mediaLibrarySession = null
} }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
@ -234,19 +295,27 @@ abstract class AbstractPlayerService : LifecycleService() {
abstract fun getChapters(): List<ChapterSegment> 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() { inner class LocalBinder : Binder() {
// Return this instance of [AbstractPlayerService] so clients can call public methods // Return this instance of [AbstractPlayerService] so clients can call public methods
fun getService(): AbstractPlayerService = this@AbstractPlayerService fun getService(): AbstractPlayerService = this@AbstractPlayerService
} }
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent?): IBinder {
super.onBind(intent) // attempt to return [MediaLibraryServiceBinder] first if matched
return binder 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 package com.github.libretube.services
import android.content.Intent 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.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi 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.DownloadWithItems
import com.github.libretube.db.obj.filterByTab import com.github.libretube.db.obj.filterByTab
import com.github.libretube.enums.FileType 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.toAndroidUri
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper 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.MainActivity
import com.github.libretube.ui.activities.NoInternetActivity import com.github.libretube.ui.activities.NoInternetActivity
import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -30,9 +32,10 @@ import kotlin.io.path.exists
/** /**
* A service to play downloaded audio in the background * A service to play downloaded audio in the background
*/ */
@UnstableApi @OptIn(UnstableApi::class)
class OfflinePlayerService : AbstractPlayerService() { open class OfflinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = true override val isOfflinePlayer: Boolean = true
override val isAudioOnlyPlayer: Boolean = true
private var noInternetService: Boolean = false private var noInternetService: Boolean = false
override val intentActivity: Class<*> override val intentActivity: Class<*>
get() = if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java get() = if (noInternetService) NoInternetActivity::class.java else MainActivity::class.java
@ -41,17 +44,19 @@ class OfflinePlayerService : AbstractPlayerService() {
private lateinit var downloadTab: DownloadTab private lateinit var downloadTab: DownloadTab
private var shuffle: Boolean = false private var shuffle: Boolean = false
override suspend fun onServiceCreated(intent: Intent) { private val scope = CoroutineScope(Dispatchers.Main)
downloadTab = intent.serializableExtra(IntentData.downloadTab)!!
shuffle = intent.getBooleanExtra(IntentData.shuffle, false) override suspend fun onServiceCreated(args: Bundle) {
noInternetService = intent.getBooleanExtra(IntentData.noInternet, false) downloadTab = args.serializable(IntentData.downloadTab)!!
shuffle = args.getBoolean(IntentData.shuffle, false)
noInternetService = args.getBoolean(IntentData.noInternet, false)
videoId = if (shuffle) { videoId = if (shuffle) {
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
Database.downloadDao().getRandomVideoIdByFileType(FileType.AUDIO) Database.downloadDao().getRandomVideoIdByFileType(FileType.AUDIO)
} }
} else { } else {
intent.getStringExtra(IntentData.videoId) args.getString(IntentData.videoId)
} ?: return } ?: return
PlayingQueue.clear() PlayingQueue.clear()
@ -66,7 +71,7 @@ class OfflinePlayerService : AbstractPlayerService() {
/** /**
* Attempt to start an audio player with the given download items * Attempt to start an audio player with the given download items
*/ */
override suspend fun startPlaybackAndUpdateNotification() { override suspend fun startPlayback() {
val downloadWithItems = withContext(Dispatchers.IO) { val downloadWithItems = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId) Database.downloadDao().findById(videoId)
}!! }!!
@ -75,13 +80,20 @@ class OfflinePlayerService : AbstractPlayerService() {
PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem()) PlayingQueue.updateCurrent(downloadWithItems.download.toStreamItem())
val notificationData = PlayerNotificationData( withContext(Dispatchers.Main) {
title = downloadWithItems.download.title, setMediaItem(downloadWithItems)
uploaderName = downloadWithItems.download.uploader, exoPlayer?.playWhenReady = PlayerHelper.playAutomatically
thumbnailPath = downloadWithItems.download.thumbnailPath exoPlayer?.prepare()
)
nowPlayingNotification?.updatePlayerNotification(videoId, notificationData)
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() } val audioItem = downloadWithItems.downloadItems.filter { it.path.exists() }
.firstOrNull { it.type == FileType.AUDIO } .firstOrNull { it.type == FileType.AUDIO }
?: // in some rare cases, video files can contain audio ?: // in some rare cases, video files can contain audio
@ -94,17 +106,10 @@ class OfflinePlayerService : AbstractPlayerService() {
val mediaItem = MediaItem.Builder() val mediaItem = MediaItem.Builder()
.setUri(audioItem.path.toAndroidUri()) .setUri(audioItem.path.toAndroidUri())
.setMetadata(downloadWithItems.download)
.build() .build()
player?.setMediaItem(mediaItem) exoPlayer?.setMediaItem(mediaItem)
player?.playWhenReady = PlayerHelper.playAutomatically
player?.prepare()
if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, downloadWithItems.download.duration)?.let {
player?.seekTo(it)
}
}
} }
private suspend fun fillQueue() { private suspend fun fillQueue() {
@ -124,8 +129,8 @@ class OfflinePlayerService : AbstractPlayerService() {
this.videoId = videoId this.videoId = videoId
lifecycleScope.launch { scope.launch {
startPlaybackAndUpdateNotification() startPlayback()
} }
} }

View File

@ -1,8 +1,7 @@
package com.github.libretube.services package com.github.libretube.services
import android.content.Intent import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.Player 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.api.obj.Streams
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHelper 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.setMetadata
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.PlayerHelper.checkForSegments import com.github.libretube.helpers.PlayerHelper.checkForSegments
import com.github.libretube.helpers.ProxyHelper import com.github.libretube.helpers.ProxyHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.parcelable.PlayerData import com.github.libretube.parcelable.PlayerData
import com.github.libretube.ui.activities.MainActivity import com.github.libretube.ui.activities.MainActivity
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -36,6 +35,7 @@ import kotlinx.serialization.encodeToString
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class OnlinePlayerService : AbstractPlayerService() { class OnlinePlayerService : AbstractPlayerService() {
override val isOfflinePlayer: Boolean = false override val isOfflinePlayer: Boolean = false
override val isAudioOnlyPlayer: Boolean = true
override val intentActivity: Class<*> = MainActivity::class.java override val intentActivity: Class<*> = MainActivity::class.java
// PlaylistId/ChannelId for autoplay // PlaylistId/ChannelId for autoplay
@ -53,8 +53,10 @@ class OnlinePlayerService : AbstractPlayerService() {
private var sponsorBlockSegments = listOf<Segment>() private var sponsorBlockSegments = listOf<Segment>()
private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories() private var sponsorBlockConfig = PlayerHelper.getSponsorBlockCategories()
override suspend fun onServiceCreated(intent: Intent) { private val scope = CoroutineScope(Dispatchers.IO)
val playerData = intent.parcelableExtra<PlayerData>(IntentData.playerData)
override suspend fun onServiceCreated(args: Bundle) {
val playerData = args.parcelable<PlayerData>(IntentData.playerData)
if (playerData == null) { if (playerData == null) {
stopSelf() stopSelf()
return return
@ -72,7 +74,7 @@ class OnlinePlayerService : AbstractPlayerService() {
} }
} }
override suspend fun startPlaybackAndUpdateNotification() { override suspend fun startPlayback() {
val timestamp = startTimestamp ?: 0L val timestamp = startTimestamp ?: 0L
startTimestamp = null startTimestamp = null
@ -110,30 +112,24 @@ class OnlinePlayerService : AbstractPlayerService() {
} }
private fun playAudio(seekToPosition: Long) { private fun playAudio(seekToPosition: Long) {
lifecycleScope.launch(Dispatchers.IO) { scope.launch {
setMediaItem() setMediaItem()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// seek to the previous position if available // seek to the previous position if available
if (seekToPosition != 0L) { if (seekToPosition != 0L) {
player?.seekTo(seekToPosition) exoPlayer?.seekTo(seekToPosition)
} else if (PlayerHelper.watchPositionsAudio) { } else if (PlayerHelper.watchPositionsAudio) {
PlayerHelper.getStoredWatchPosition(videoId, streams?.duration)?.let { 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)) } streams?.let { onNewVideoStarted?.invoke(it.toStreamItem(videoId)) }
player?.apply { exoPlayer?.apply {
playWhenReady = PlayerHelper.playAutomatically playWhenReady = PlayerHelper.playAutomatically
prepare() prepare()
} }
@ -146,7 +142,7 @@ class OnlinePlayerService : AbstractPlayerService() {
*/ */
private fun playNextVideo(nextId: String? = null) { private fun playNextVideo(nextId: String? = null) {
if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) { if (nextId == null && PlayingQueue.repeatMode == Player.REPEAT_MODE_ONE) {
player?.seekTo(0) exoPlayer?.seekTo(0)
return return
} }
@ -161,13 +157,13 @@ class OnlinePlayerService : AbstractPlayerService() {
this.streams = null this.streams = null
this.sponsorBlockSegments = emptyList() this.sponsorBlockSegments = emptyList()
lifecycleScope.launch { scope.launch {
startPlaybackAndUpdateNotification() startPlayback()
} }
} }
/** /**
* Sets the [MediaItem] with the [streams] into the [player] * Sets the [MediaItem] with the [streams] into the [exoPlayer]
*/ */
private suspend fun setMediaItem() { private suspend fun setMediaItem() {
val streams = streams ?: return val streams = streams ?: return
@ -185,14 +181,14 @@ class OnlinePlayerService : AbstractPlayerService() {
.setMimeType(mimeType) .setMimeType(mimeType)
.setMetadata(streams) .setMetadata(streams)
.build() .build()
withContext(Dispatchers.Main) { player?.setMediaItem(mediaItem) } withContext(Dispatchers.Main) { exoPlayer?.setMediaItem(mediaItem) }
} }
/** /**
* fetch the segments for SponsorBlock * fetch the segments for SponsorBlock
*/ */
private fun fetchSponsorBlockSegments() { private fun fetchSponsorBlockSegments() {
lifecycleScope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runCatching { runCatching {
if (sponsorBlockConfig.isEmpty()) return@runCatching if (sponsorBlockConfig.isEmpty()) return@runCatching
sponsorBlockSegments = RetrofitInstance.api.getSegments( sponsorBlockSegments = RetrofitInstance.api.getSegments(
@ -210,7 +206,7 @@ class OnlinePlayerService : AbstractPlayerService() {
private fun checkForSegments() { private fun checkForSegments() {
handler.postDelayed(this::checkForSegments, 100) handler.postDelayed(this::checkForSegments, 100)
player?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig) exoPlayer?.checkForSegments(this, sponsorBlockSegments, sponsorBlockConfig)
} }
override fun onPlaybackStateChanged(playbackState: Int) { 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 // 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 // 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 // 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) } 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 package com.github.libretube.ui.activities
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope 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.common.Player
import androidx.media3.datasource.FileDataSource import androidx.media3.session.MediaController
import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.session.SessionToken
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.github.libretube.compat.PictureInPictureCompat import com.github.libretube.compat.PictureInPictureCompat
import com.github.libretube.compat.PictureInPictureParamsCompat 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.FileType
import com.github.libretube.enums.PlayerEvent import com.github.libretube.enums.PlayerEvent
import com.github.libretube.extensions.serializableExtra import com.github.libretube.extensions.serializableExtra
import com.github.libretube.extensions.toAndroidUri import com.github.libretube.helpers.BackgroundHelper
import com.github.libretube.extensions.updateParameters
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.helpers.WindowHelper 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.base.BaseActivity
import com.github.libretube.ui.fragments.DownloadTab import com.github.libretube.ui.fragments.DownloadTab
import com.github.libretube.ui.interfaces.TimeFrameReceiver import com.github.libretube.ui.interfaces.TimeFrameReceiver
import com.github.libretube.ui.listeners.SeekbarPreviewListener import com.github.libretube.ui.listeners.SeekbarPreviewListener
import com.github.libretube.ui.models.ChaptersViewModel import com.github.libretube.ui.models.ChaptersViewModel
import com.github.libretube.ui.models.CommonPlayerViewModel 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.OfflineTimeFrameReceiver
import com.github.libretube.util.PauseableTimer import com.github.libretube.util.PauseableTimer
import com.github.libretube.util.PlayingQueue import com.github.libretube.util.PlayingQueue
@ -61,13 +52,13 @@ import kotlin.io.path.exists
class OfflinePlayerActivity : BaseActivity() { class OfflinePlayerActivity : BaseActivity() {
private lateinit var binding: ActivityOfflinePlayerBinding private lateinit var binding: ActivityOfflinePlayerBinding
private lateinit var videoId: String private lateinit var videoId: String
private lateinit var playerController: MediaController
private lateinit var playerView: PlayerView private lateinit var playerView: PlayerView
private var timeFrameReceiver: TimeFrameReceiver? = null private var timeFrameReceiver: TimeFrameReceiver? = null
private var nowPlayingNotification: NowPlayingNotification? = null
private lateinit var playerBinding: ExoStyledPlayerControlViewBinding private lateinit var playerBinding: ExoStyledPlayerControlViewBinding
private val commonPlayerViewModel: CommonPlayerViewModel by viewModels() private val commonPlayerViewModel: CommonPlayerViewModel by viewModels()
private val viewModel: OfflinePlayerViewModel by viewModels { OfflinePlayerViewModel.Factory }
private val chaptersViewModel: ChaptersViewModel by viewModels() private val chaptersViewModel: ChaptersViewModel by viewModels()
private val watchPositionTimer = PauseableTimer( private val watchPositionTimer = PauseableTimer(
@ -82,6 +73,13 @@ class OfflinePlayerActivity : BaseActivity() {
playerBinding.duration.text = DateUtils.formatElapsedTime( playerBinding.duration.text = DateUtils.formatElapsedTime(
player.duration / 1000 player.duration / 1000
) )
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
requestedOrientation = PlayerHelper.getOrientation(
playerController.videoSize.width,
playerController.videoSize.height
)
}
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -110,7 +108,7 @@ class OfflinePlayerActivity : BaseActivity() {
SeekbarPreviewListener( SeekbarPreviewListener(
timeFrameReceiver ?: return, timeFrameReceiver ?: return,
binding.player.binding, binding.player.binding,
viewModel.player.duration playerController.duration
) )
) )
} }
@ -124,7 +122,7 @@ class OfflinePlayerActivity : BaseActivity() {
private val playerActionReceiver = object : BroadcastReceiver() { private val playerActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return val event = intent.serializableExtra<PlayerEvent>(PlayerHelper.CONTROL_TYPE) ?: return
if (PlayerHelper.handlePlayerAction(viewModel.player, event)) return if (PlayerHelper.handlePlayerAction(playerController, event)) return
when (event) { when (event) {
PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return) PlayerEvent.Prev -> playNextVideo(PlayingQueue.getPrev() ?: return)
@ -135,17 +133,23 @@ class OfflinePlayerActivity : BaseActivity() {
} }
private val pipParams private val pipParams
get() = PictureInPictureParamsCompat.Builder() get() = run {
.setActions(PlayerHelper.getPiPModeActions(this, viewModel.player.isPlaying)) val isPlaying = ::playerController.isInitialized && playerController.isPlaying
.setAutoEnterEnabled(PlayerHelper.pipEnabled && viewModel.player.isPlaying)
.setAspectRatio(viewModel.player.videoSize) PictureInPictureParamsCompat.Builder()
.setActions(PlayerHelper.getPiPModeActions(this,isPlaying))
.setAutoEnterEnabled(PlayerHelper.pipEnabled && isPlaying)
.apply {
if (isPlaying) {
setAspectRatio(playerController.videoSize)
}
}
.build() .build()
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowHelper.toggleFullscreen(window, true) WindowHelper.toggleFullscreen(window, true)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
videoId = intent?.getStringExtra(IntentData.videoId)!! videoId = intent?.getStringExtra(IntentData.videoId)!!
@ -160,13 +164,19 @@ class OfflinePlayerActivity : BaseActivity() {
playNextVideo(streamItem.url ?: return@setOnQueueTapListener) playNextVideo(streamItem.url ?: return@setOnQueueTapListener)
} }
initializePlayer() val sessionToken = SessionToken(
playVideo() this,
ComponentName(this, VideoOfflinePlayerService::class.java)
requestedOrientation = PlayerHelper.getOrientation(
viewModel.player.videoSize.width,
viewModel.player.videoSize.height
) )
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( ContextCompat.registerReceiver(
this, this,
@ -188,14 +198,11 @@ class OfflinePlayerActivity : BaseActivity() {
playVideo() playVideo()
} }
private fun initializePlayer() { private fun initializePlayerView() {
viewModel.player.setWakeMode(C.WAKE_MODE_LOCAL)
viewModel.player.addListener(playerListener)
playerView = binding.player playerView = binding.player
playerView.setShowSubtitleButton(true) playerView.setShowSubtitleButton(true)
playerView.subtitleView?.isVisible = true playerView.subtitleView?.isVisible = true
playerView.player = viewModel.player playerView.player = playerController
playerBinding = binding.player.binding playerBinding = binding.player.binding
playerBinding.fullscreen.isInvisible = true playerBinding.fullscreen.isInvisible = true
@ -216,13 +223,6 @@ class OfflinePlayerActivity : BaseActivity() {
binding.playerGestureControlsView.binding, binding.playerGestureControlsView.binding,
chaptersViewModel chaptersViewModel
) )
nowPlayingNotification = NowPlayingNotification(
this,
viewModel.player,
offlinePlayer = true,
intentActivity = OfflinePlayerActivity::class.java
)
} }
private fun playVideo() { private fun playVideo() {
@ -230,7 +230,6 @@ class OfflinePlayerActivity : BaseActivity() {
val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) { val (downloadInfo, downloadItems, downloadChapters) = withContext(Dispatchers.IO) {
Database.downloadDao().findById(videoId) Database.downloadDao().findById(videoId)
}!! }!!
PlayingQueue.updateCurrent(downloadInfo.toStreamItem())
val chapters = downloadChapters.map(DownloadChapter::toChapterSegment) val chapters = downloadChapters.map(DownloadChapter::toChapterSegment)
chaptersViewModel.chaptersLiveData.value = chapters chaptersViewModel.chaptersLiveData.value = chapters
@ -240,88 +239,15 @@ class OfflinePlayerActivity : BaseActivity() {
playerBinding.exoTitle.text = downloadInfo.title playerBinding.exoTitle.text = downloadInfo.title
playerBinding.exoTitle.isVisible = true playerBinding.exoTitle.isVisible = true
val video = downloadFiles.firstOrNull { it.type == FileType.VIDEO } timeFrameReceiver = downloadFiles.firstOrNull { it.type == FileType.VIDEO }?.path?.let {
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 {
OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it) OfflineTimeFrameReceiver(this@OfflinePlayerActivity, it)
} }
viewModel.player.playWhenReady = PlayerHelper.playAutomatically
viewModel.player.prepare()
if (PlayerHelper.watchPositionsVideo) { if (PlayerHelper.watchPositionsVideo) {
PlayerHelper.getStoredWatchPosition(videoId, downloadInfo.duration)?.let { 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() { private fun saveWatchPosition() {
if (!PlayerHelper.watchPositionsVideo) return if (!PlayerHelper.watchPositionsVideo) return
PlayerHelper.saveWatchPosition(viewModel.player, videoId) PlayerHelper.saveWatchPosition(playerController, videoId)
} }
override fun onResume() { override fun onResume() {
@ -349,19 +275,17 @@ class OfflinePlayerActivity : BaseActivity() {
super.onPause() super.onPause()
if (PlayerHelper.pauseOnQuit) { if (PlayerHelper.pauseOnQuit) {
viewModel.player.pause() playerController.pause()
} }
} }
override fun onDestroy() { override fun onDestroy() {
saveWatchPosition() saveWatchPosition()
nowPlayingNotification?.destroySelf()
nowPlayingNotification = null
watchPositionTimer.destroy() watchPositionTimer.destroy()
runCatching { runCatching {
viewModel.player.stop() playerController.stop()
} }
runCatching { runCatching {
@ -372,7 +296,7 @@ class OfflinePlayerActivity : BaseActivity() {
} }
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
if (PlayerHelper.pipEnabled && viewModel.player.isPlaying) { if (PlayerHelper.pipEnabled && playerController.isPlaying) {
PictureInPictureCompat.enterPictureInPictureMode(this, pipParams) PictureInPictureCompat.enterPictureInPictureMode(this, pipParams)
} }

View File

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

View File

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

View File

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

View File

@ -1,75 +1,35 @@
package com.github.libretube.util package com.github.libretube.util
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle 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.app.PendingIntentCompat
import androidx.core.content.getSystemService import androidx.media3.session.CommandButton
import androidx.media.app.NotificationCompat.MediaStyle import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.common.MediaMetadata import androidx.media3.session.MediaNotification
import androidx.media3.common.Player import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.ExoPlayer
import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME import com.github.libretube.LibreTubeApp.Companion.PLAYER_CHANNEL_NAME
import com.github.libretube.R import com.github.libretube.R
import com.github.libretube.constants.IntentData import com.github.libretube.constants.IntentData
import com.github.libretube.enums.NotificationId import com.github.libretube.enums.NotificationId
import com.github.libretube.enums.PlayerEvent 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.helpers.PlayerHelper
import com.github.libretube.obj.PlayerNotificationData
import com.github.libretube.services.OnClearFromRecentService
import com.github.libretube.ui.activities.MainActivity 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) @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class NowPlayingNotification( class NowPlayingNotification(
private val context: Context, private val context: Context,
private val player: ExoPlayer,
private val backgroundOnly: Boolean = false, private val backgroundOnly: Boolean = false,
private val offlinePlayer: Boolean = false, private val offlinePlayer: Boolean = false,
private val intentActivity: Class<*> = MainActivity::class.java private val intentActivity: Class<*> = MainActivity::class.java
) { ): MediaNotification.Provider {
private var videoId: String? = null private val nProvider = DefaultMediaNotificationProvider.Builder(context)
private val nManager = context.getSystemService<NotificationManager>()!! .setNotificationId(NotificationId.PLAYER_PLAYBACK.id)
.setChannelId(PLAYER_CHANNEL_NAME)
/** .setChannelName(R.string.player_channel_name)
* The metadata of the current playing song (thumbnail, title, uploader) .build()
*/
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()
}
}
}
private fun createCurrentContentIntent(): PendingIntent? { private fun createCurrentContentIntent(): PendingIntent? {
// starts a new MainActivity Intent when the player notification is clicked // starts a new MainActivity Intent when the player notification is clicked
@ -84,194 +44,10 @@ class NowPlayingNotification(
} }
} }
return PendingIntentCompat return PendingIntentCompat
.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT, false) .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) * Forward the action to the responsible notification owner (e.g. PlayerFragment)
*/ */
@ -282,79 +58,23 @@ class NowPlayingNotification(
context.sendBroadcast(intent) context.sendBroadcast(intent)
} }
/** override fun createNotification(
* Updates or creates the [notificationBuilder] mediaSession: MediaSession,
*/ customLayout: ImmutableList<CommandButton>,
fun updatePlayerNotification(videoId: String, data: PlayerNotificationData) { actionFactory: MediaNotification.ActionFactory,
this.videoId = videoId onNotificationChangedCallback: MediaNotification.Provider.Callback
this.notificationData = data ): MediaNotification {
// reset the thumbnail bitmap in order to become reloaded for the new video createCurrentContentIntent()?.let { mediaSession.setSessionActivity(it) }
this.notificationBitmap = null nProvider.setSmallIcon(R.drawable.ic_launcher_lockscreen)
return nProvider.createNotification(mediaSession, customLayout, actionFactory, onNotificationChangedCallback)
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 handleCustomCommand(
} session: MediaSession,
action: String,
/** extras: Bundle
* Initializes the [notificationBuilder] attached to the [player] and shows it. ): Boolean {
*/ runCatching { handlePlayerAction(PlayerEvent.valueOf(action)) }
private fun createNotificationBuilder() { return true
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()
} }
} }